Skip to content

feat(apps): Spec Builder builtin — spec-driven development surface - #518

Merged
bolichen97 merged 1 commit into
mainfrom
feat/spec-builder-builtin
Aug 5, 2026
Merged

feat(apps): Spec Builder builtin — spec-driven development surface#518
bolichen97 merged 1 commit into
mainfrom
feat/spec-builder-builtin

Conversation

@kyleseaman

@kyleseaman kyleseaman commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Ports the external kiro-specs app into a native builtin: Spec Builder, a spec-driven development surface. Describe a feature in chat, an embedded agent drives Requirements → Design → Tasks → Execution with phase gates you approve, and the artifacts land as plain markdown in <project>/.kiro/specs/<name>/ so they stay compatible with the Kiro IDE and CLI.

Three columns: a collapsible specs rail, the native chat (ChatEmbed), and a docs card with selection-to-comment review, batched feedback, and phase-gated approval actions. Structured agent state (.spec-state.json) surfaces as DECISIONS / BLOCKING / CONTEXT cards.

Style-guide adherence

The external app hand-rolled a lot of UI. A review against website/AUTOSDE.yaml and website/AGENTS.md found 12 findings, and this port closes all of them. Notably, every fix removed code rather than adding it — each one replaced a local reinvention with the shared component that already existed:

Finding Fix
Hand-rolled doc tabs shared SegmentedControl — the per-file status dot rides its icon slot, and responsive full→compact→dropdown collapse comes free (it sits in a user-resizable column)
Hand-rolled modal overlay shared Modal — supplies role="dialog", aria-modal, Escape + backdrop dismissal, scroll lock, labelled close
11 raw <button> elements shared Btn; the local pill button is now a thin wrapper over it instead of 18 hand-written style props
26 size={N} icon props, inline-flex wrappers, text glyphs as icons className="lucide-inline" throughout; IconText deleted; ✕ › ←X / ChevronRight / ArrowLeft
<div onClick> / <span onClick> Clickable (role, tabIndex, Enter/Space) for spec rows, folder rows, decision options, type cards
No aria-label on icon-only controls every icon-only control now carries one
No aria-live on streaming regions working indicator, rail running dot, browse path; role="alert" on the error banner
CSS @keyframes Framer Motion (PULSE_MOTION)
Off-scale typography incl. one 9.5px below the hard floor normalised onto 14 / 13 / 12 / 11; no text-xs
Mouse-only resize divider arrow-key resize plus the W3C APG window-splitter pattern (role="separator" + aria-valuenow/min/max)

The two eslint warnings the splitter trips are a linter blind spot — no-noninteractive-tabindex and no-noninteractive-element-interactions don't model the APG splitter pattern, which is interactive once focusable. Suppressed at the element with the reasoning written inline rather than reshaped into a <button>, which would announce the wrong role and lose the value semantics.

Verification

  • tsc -b clean (the root tsconfig.json is references-only, so tsc --noEmit -p tsconfig.json checks nothing — tsc -b is the real gate)
  • eslint src/apps/spec-builder0 errors, 0 warnings (was 6 warnings)
  • vitest — 5321 passed
  • backend pytest — 141 app tests + the full 19212-test suite; mypy (509 files), flake8, isort clean
  • Every fix in the review rounds below is revert-verified: the fix is removed, a test is shown failing, then it is restored.
  • 4 new accessibility regression tests. The strictest asserts that no button reaches the DOM without a discernible name and prints the offending element; revert-verified by removing a single aria-label and confirming it fails.

Note on a pre-existing failure

src/test/KiroGhostMark.test.tsx fails 2 mask-image assertions. This is not from this branch — I reproduced it with these changes stashed. Main's happy-dom test-environment migration broke a test written for jsdom, so it currently fails on any PR. Flagging rather than fixing here to keep this diff scoped.

Changes outside the app directory

Two core files carry fixes this app surfaced, kept here because the app is the
thing that exposes them:

  • src/kiro_crew/autonudge.pyremove() fsynced on the event loop. All four
    write sites now go through _write_payload_locked, which offloads and, on
    cancellation, drains to completion so a later write cannot land before an
    earlier one.
  • src/kiro_crew/dashboard/chat_persistence.py — added
    arehydrate_slot_from_history, an async twin of the existing per-slot resume
    whose transcript read runs in a worker thread. Slots live in memory, so a
    gateway restart left an app's chat column empty with the whole conversation
    still on disk; app backends resolving a cold worker slot need to restore it on
    the request path without stalling the loop on a multi-megabyte session file.

Follow-ups deliberately not in scope

  • The external app at kiro-specs-app still carries its own copy of these issues (notably a light-mode-only phase pill). It is superseded by this builtin; retiring it is a separate decision.
  • ChatEmbed gained frameless and startAtBottom props here, which let the app drop CSS !important overrides. onApprove is now wired through the embed too (an embedded agent that hit a permission prompt previously rendered a dead "Approval needed" label with no buttons).

Screenshots

Captured against a real isolated instance of this branch — its own port, its
own KIROCREW_HOME, --no-crons, live gateway untouched — via
website/scripts/capture-spec-builder.mjs (committed). The harness asserts each
state rendered before it shoots, so a blank page or an auth failure fails the run
rather than producing a plausible-looking empty image.

Collapsed rail

Dragging the rail past its minimum collapses it to a bordered strip that keeps
Settings and the app identity reachable.

Spec selected — chat | docs split

The main surface: rail with two specs, the embedded agent session, and a rendered
requirements.md with phase tabs (shared SegmentedControl, status dot in its
icon slot), the phase-gated Approve → Tasks action, and the CONTEXT panel.

Spec detail, dark

Light palette — the app is design-token driven, so both palettes are meaningful
variants rather than a cosmetic duplicate:

Spec detail, light

First run

Centred, icon-paired empty state. The rail stays mounted — it previously
unmounted here, taking the app-identity footer and the Settings entry point with
it (the screenshot assertion is what caught that).

First run, dark

More surfaces — rail populated, new-spec view, project picker, light first run

Rail with both specs, nothing selected:

Rail populated

The conversational creator — spec-type cards are Clickable toggle buttons with
aria-pressed, not bare <div onClick>:

New spec

Project picker — folder browser with recents, keyboard-operable rows:

Project picker

First run, light palette:

First run, light

Honest scope note on the fixtures: the two specs in these shots are fixtures
written to disk in the isolated instance's spec dirs, not the output of a live
agent run. They exercise the real read path, rendering and layout, but they are
not evidence that the Requirements → Design → Tasks agent loop works end to end.
The skeleton states are covered by unit tests rather than screenshots, since they
resolve too quickly to capture reliably.

Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch from 1ed6500 to 01be8c5 Compare July 26, 2026 18:44
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 14f96a9dad65d2f5bc55d07a08abc5c7bd9f402b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 14f96a9

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 14f96a9dad65d2f5bc55d07a08abc5c7bd9f402b: <one-sentence reason>

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Arbiter — ✅ no blocking findings

Arbiter found no unresolved long-term items that require action before merging eed0e113278dd1af1926bc60efb39e209985e374.

Second-order review for eed0e113278dd1af1926bc60efb39e209985e374; this comment is updated in place on each push.

Review details

Both input files read. The line-level reviewers (Opus 5, GPT 5.6) reported no Medium/Low findings, so the pool to judge is the design reviewer's two Watch items + one suggestion and the UX reviewer's two Watch items + two suggestions. None of them clears the narrow bar:

  • The "phantom description item" is an audit-trail/description inaccuracy — no code in this diff is wrong because of it, no contract is locked in, and it can be corrected in the PR description or a follow-up note at any time.
  • The untested end-to-end loop ships behind defaultEnabled: false; the design reviewer itself calls it "acceptable to merge," and an offline E2E run can gate the default-flip later.
  • The i18n gaps, missing delete affordance, missing module spec, and the prompt/Clear-button UX items are all reversible in later UI/docs changes with no migration or breaking-change cost.

Arbiter-Verdict: PASS

No sub-threshold finding meets the long-term-impact bar.

Suggested follow-ups (open as issues — non-blocking)

  • Correct the PR description's phantom autonudge claim (Design · Watch): the description credits this PR with an autonudge.py _write_payload_locked fix the diff does not contain; fix the description (or link the PR where that change actually landed) before or immediately after merge so the merged audit trail matches reality. Zero code change needed.
  • Offline E2E run of the Requirements → Design → Tasks → Execution loop (Design · Watch): run the spec-builder flow against the packaged fake ACP backend (the setup.py test_e2e harness already exists) and make it a precondition for flipping defaultEnabled to true — the flag being off is what makes deferral safe.
  • Add docs/system-specs/modules/spec-builder.md (Design · Suggestion): routes, .spec-state.json schema, handoff/authz flow, new ChatEmbed props, arehydrate_slot_from_history — sibling builtins have module specs and the repo's spec-management rule expects one in-tree; a docs-only follow-up commit closes it.
  • Route the hardcoded English strings through the i18n catalog (UX · Watch): ADVANCE labels, PHASE_LABEL, SPEC_TYPES, FRIENDLY empty states, SpecStatePanel and SpecRail literals in the spec-builder frontend — a mixed-language surface for non-English users, but a pure string-catalog change, reversible any time.
  • Wire a delete affordance to the existing specApi.remove/backend DELETE (UX · Watch): the backend delete + tombstone machinery is fully built in this PR; only the UI call site is missing, so specs accumulate until it lands. Small frontend addition.
  • Move the decision_title template into prompts.ts (UX · Suggestion): a translated string is currently sent to the agent, the exact defect prompts.ts documents itself as preventing — one-line relocation.
  • Make the comment tray's Clear state its cost (UX · Suggestion): restate the count ("Discard 3 comments") or add undo so typed review notes aren't silently discarded.

[ARBITER-REVIEWED] eed0e11

False positive or not applicable? A repository writer can comment:
/ai-review override arbiter eed0e113278dd1af1926bc60efb39e209985e374: <one-sentence reason>

For a broader accepted-risk deferral, apply defer-longterm and explain why.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 14f96a9dad65d2f5bc55d07a08abc5c7bd9f402b — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound shape — reuses core chat slots, ChatEmbed, and autonudge instead of a private runner — but the description claims a core fix the diff doesn't contain.

Watch

  • Phantom description on autonudge.py. The body states "All four write sites now go through _write_payload_locked" as a change this PR carries — but the diff touches only test/test_autonudge.py, and _write_payload_locked exists nowhere in the repo (the actual mechanism on main is remove_sync(persist=False) + shield/drain). The claimed fix either landed elsewhere or is described against a different implementation; a reviewer auditing the concurrency story from the description will look for code that isn't there. messaging/link.py's regex-bounding hunk is likewise absent from the description.
  • The core user journey is unverified, yet it ends in autonomous execution. The PR's own scope note says the fixtures "are not evidence that the Requirements → Design → Tasks agent loop works end to end" — while _exec_prompt + authorize_and_add_nudge hands an approved plan to an up-to-60-cycle (_EXEC_MAX_CYCLES = 60) unattended loop with STOP sentinels and execution claims. The most consequential path ships on mocked-slot unit tests only; a first real run is where the claim/halt/worktree machinery gets exercised.

Suggestions

  • Split the 3,495-line backend/routes.py (index/tombstones, slot lifecycle, git worktrees, execution claiming, prompts, HTTP handlers) into modules as pptx_maker/backend/ and papyrus/backend/ already do — same code, ownable pieces.

[DESIGN-REVIEWED] 14f96a9

@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch from 01be8c5 to 1de2ea7 Compare July 26, 2026 18:52
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch 2 times, most recently from bdfffdc to a41cfcf Compare July 27, 2026 02:14
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Jul 27, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 2 disposition — all 5 HIGHs fixed (a41cfcf7)

All five were legitimate. One (#5) was a regression introduced by the previous round's own path-injection hardening — thanks for catching it.

1. Spec reads / STOP writes follow untrusted symlinks — FIXED. Correct premise: the spec directory passed _safe_dir, but a file inside it is agent- and user-writable and so is untrusted input. Reads now go through _spec_file / _read_spec_text and the STOP write through _write_stop_sentinel, which refuse symlinks, require realpath containment inside the spec dir, refuse sensitive realpaths, and write atomically via O_NOFOLLOW + os.replaceos.replace swaps the link itself rather than writing through it, so a planted STOP symlink is destroyed instead of honoured. Test proves a planted link leaves its victim byte-identical.

2. Autonomous execution bypasses approval + AutoNudge controls — FIXED. Now routed through authorize_and_add_nudge, inheriting the shared slot-ownership checks, message limit, sensitive-sentinel refusal and SEL audit; it fails closed (revokes trust, returns 403) if authorization is refused. max_cycles 0 (infinite) → 60. The auto-approve grant is bounded three ways: a TTL enforced on the status poll, explicit revocation on Stop, and the cycle cap. Note Stop previously revoked nothing — trust stayed set on the slot after stopping — so grant and revocation now share one _halt_execution chokepoint. A missing start timestamp fails closed rather than skipping the TTL.

Scope note: slot._trust is boolean in core with no expiry field, so rather than add an expiry mechanism across the gateway this mirrors the auto_research builtin's existing trust-cap precedent (grant → TTL → revoke).

3. LLM-authored state served without schema validation — FIXED. .spec-state.json is projected onto the documented schema: unknown keys dropped, types enforced, lists capped. Your sharpest point — credentials in object keys bypassing the value-only recursive scrub — is closed by only emitting a fixed key set. decisions: [null] and other malformed entries are dropped rather than forwarded to the panel.

4. Create can overwrite an existing Kiro spec — FIXED. Returns 409 when the target already contains any of the three Kiro markdown files, so an IDE/CLI-authored spec is never handed to an agent implicitly; adoption is explicit via import_existing. Test asserts the pre-existing file is untouched.

5. Worktree creation always fails containment — FIXED. Exactly right, and worse than reported: it failed unconditionally in worktree mode, and the already-created worktree was orphaned on the 400. A worktree is a sibling of the checkout, so it is now re-validated through _safe_dir and becomes the containment root; every failure path after creation rolls it back via _remove_worktree (prune before branch -D, since a leftover registration keeps the branch checked out).

Verification: 14 new tests (34 in the builtin), each guard revert-verified — neutering it fails the corresponding test. flake8 / isort / mypy / tsc clean; spawn audit passes.

Also on this head: the prior round's _git spawn is routed through sandboxed_spawn_argv rather than allowlisted, since its working dir is caller-supplied.

Unrelated red to be aware of: src/test/KiroGhostMark.test.tsx fails 2 mask-image assertions on main itself (happy-dom migration vs a jsdom-era test) — reproduced with this branch's changes stashed, so it is not from this PR.

Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
Comment thread src/kiro_crew/apps/builtins/spec_builder/backend/routes.py Fixed
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Jul 27, 2026
@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch from a41cfcf to 311fbeb Compare July 27, 2026 14:15
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Jul 27, 2026
@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch from 311fbeb to 646626e Compare July 27, 2026 14:37
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Jul 27, 2026
@kyleseaman
kyleseaman force-pushed the feat/spec-builder-builtin branch from f7efdcf to f2f2db3 Compare July 27, 2026 17:25
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Jul 27, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 65 — rebase only, no code change.

GPT 5.6 on bfef12d2: success, no blocking findings (05:44:51 → 05:51:11) — fourth consecutive clean verdict. Nothing was flagged, so nothing in the diff moved.

Why the new SHA. Opus 5 Review hit its 30-minute wall-clock ceiling twice on bfef12d2 (05:44:43 → 06:14:58, 30m15s; 06:17:21 → 06:47:36, 30m15s). Per the ceiling rule I stopped re-running it on that head. main had meanwhile advanced 4 commits (#1134, #1132, #1127, #1131), so the branch was rebased onto origin/main — due regardless, and it gives Opus a fresh roll instead of a third re-run of the same attempt. Clean rebase: no conflicts in builtinRegistry.ts, no locale conflicts, en-XA untouched. Single commit preserved; head is now 07d8d8ea.

Gates re-run on the rebased tree:

  • pytest full suite: 23634 passed, 8 failed — all 8 are the documented pre-existing set that also fails on a pristine origin/main worktree (3× test_embedding_space_reconcile, test_app_manager::test_declares_backend_helper, 3× test_dashboard_origin, test_browser_recording_skill). No spec-builder test failed.
  • isort --check-only, flake8: clean. mypy src/kiro_crew/: Success: no issues found in 585 source files.
  • I18N_BASE_REF=origin/main npm run i18n:check: OK: 1354 untranslated strings across 249 files, at or below the baseline of 1840; 0 touched file(s) gained untranslated strings vs the base.
  • tsc -b: clean. eslint --max-warnings 1116: 0 errors, 309 warnings.
  • vitest run: 578 passed / 2 failed, both explained and neither attributable to this diff:
    • src/i18n/format.test.ts asserts the absence of Intl.DurationFormat; local Node 24.18 has it, CI's Node does not — a local-environment failure only.
    • src/test/WindowCalculator.test.ts > OffsetIndex: sub-linear scaling benchmark is a wall-clock performance assertion (expect(oiRatio).toBeLessThan(6), measured 6.53) that ran while this host was still loaded from the backend suite. Re-run in isolation 3× consecutively: 23/23 passing each time. This diff touches no file under website/src/utils or website/src/lib and nothing named WindowCalculator/offsetIndex (verified by git diff --name-only origin/main..HEAD), so it is a load-sensitive benchmark flake, not a regression.

Screenshot URLs in the PR body re-pinned to 07d8d8eac (8 of 8).

Arbiter — judge from comments remains parked for a reason outside this diff, unchanged from the round-64 note: two bot-authored <!-- design-review --> comments exist (5084890657, kept current; 5147420616, frozen at 09a6226b), and design-review.yml:346 PATCHes the first match via head -n1 while longterm-arbiter.yml:160 reads the last via jq '… | last'. The writer refreshes one comment while the reader checks the other, so present "$design" never matches the head SHA and the check sets STATE=waiting. Being handled separately, not absorbed here.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 66 — GPT's blocking finding on 07d8d8eac32e736a9facb8f7164ac95c3a07504c is fixed.

BLOCKING — routes.py:1491 — "Pause and Delete can relaunch queued work" → FIXED

Legitimate, and confirmed in the upstream code. _run_chat's except asyncio.CancelledError: (chat_runner.py:4875) swallows the cancellation without re-raising, so control falls into its end-of-turn block (finally at chat_runner.py:5181) on a cancel exactly as on a clean finish. That block then, in order:

  1. _requeue_unconsumed_steers (5253) — pushes unconsumed steers to the head of the queue, so a steer becomes a queue item;
  2. if slot._queue and not _auth_required: … _start_next_queued_turn (5255–5267) — starts a successor turn;
  3. otherwise _finish_queue_cycle (5269) — which, on slot._pending_synthesis, creates _run_pending_synthesis → another _run_chat.

So a Pause or Delete that only stopped the turn handed the agent its next prompt: it kept editing the user's spec files after the click, and for Delete it kept writing into a directory the request was about to archive.

Fix — one chokepoint, two call sites. Added _discard_queued_work(slot) and called it from both _teardown_worker_slot and _halt_active_turn, before any stop. It clears _queue, _pending_steers and sets _pending_synthesis = False — all three relaunch sources; leaving any one behind still starts a successor.

Deviations from the suggested remedy, both deliberate:

  1. Placed before the cooperative stop, not just before the cancel. The remedy said "before cancelling". In _halt_active_turn the cooperative state.sessions.stop_turn(..., force=False) runs before task.cancel(), and a cooperative stop ends the turn just as surely — so clearing only before the cancel still races the successor. The clear now precedes both stops, and the test asserts the queue is empty at the cooperative stop and at the cancel.
  2. One shared helper rather than three clears inlined twice. Fixes the class (this app cancels a turn without discarding the work queued behind it) at a single chokepoint instead of duplicating the field list.

Not absorbed: the gateway's own hard-kill path (chat_handlers.py:1059-1061) clears _queue and _pending_steers inline but not _pending_synthesis. That is a pre-existing main-wide gap, so it is not being fixed inside this feature PR.

Revert-verified — five independent mutations, each failing a NAMED test:

Reverted Named failure
delete-path discard removed test_delete_discards_queued_work_before_cancelling, test_both_stop_helpers_discard_before_they_stop
pause-path discard removed test_pause_discards_queued_work_before_stopping, test_both_stop_helpers_discard_before_they_stop
pause discard moved after the cooperative stop (order only, syntax valid) test_pause_discards_queued_work_before_stopping, test_both_stop_helpers_discard_before_they_stop
_pending_synthesis left set test_pause_…, test_delete_…, test_discard_covers_every_relaunch_source
_pending_steers left in place test_pause_…, test_delete_…, test_discard_covers_every_relaunch_source

The source guard asserts on the call order (_discard_queued_work index < stop_turn( and task.cancel() indexes in both helpers), never on its own comment. test_run_chat_still_relaunches_from_these_three_fields pins the upstream behaviour this fix exists for, so if the gateway ever stops relaunching, the guard fails loudly instead of the discard being cargo-culted.

Gates: pytest 23651 passed; the 9 failures are the 8 documented pre-existing ones plus test_apps_registry::test_fetch_app_manifest_reaps_clone_tree_on_timeout, which is order-dependent and passes 8/8 in isolation. isort/flake8 clean; mypy: Success: no issues found in 585 source files. Spec Builder's own suite: 231 passed, three consecutive runs.

Frontend gates are reused from 07d8d8ea (tsc -b clean, eslint 0 errors, i18n 1354/1840, vitest 578 pass): this round's diff is backend + tests only and the base has not moved since that run.

One test-hygiene note: the two behavioural tests initially passed alone but failed in a full-file run. Cause was mine, not the fix — _slot_key() prefers a persisted per-creation key from the module-global _SLOT_KEYS, so hardcoding spec-builder-s missed once an earlier test had created a spec named s. The fixtures now derive both the key and the owner from the module (routes._slot_key(...), routes.APP_NAME) and use a dedicated spec name.

Screenshot URLs re-pinned to 4737947aa (8 of 8).

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 67 — GPT's blocking finding on 4737947aa831fca74767c3ed37311023dca23dad is fixed. This is a NEW finding, not a repeat: round 66's Pause/Delete relaunch fix was not re-raised.

BLOCKING — routes.py:2493 — "stale create can delete a replacement spec's worktree" → FIXED

Legitimate, and the blast radius is larger than files. _unwind_create pinned its index pop on both spec_dir and slot_key — and then removed the worktree unconditionally, discarding that carefully-computed answer. _remove_worktree is git worktree remove --force, worktree prune, then git branch -D <branch>. So on a concurrent delete + same-name recreate the stale unwind would force-delete the replacement spec's worktree (losing any uncommitted work in it) and hard-delete its spec/<name> branch.

The window is real: between the _insert commit and _unwind_create there is an await _ensure_worker_slot(...), and the worktree path is derived from the name (<repo>-wt-<name>), so after a delete + recreate that path belongs to the replacement.

Fix. _mutate_index already returns True-on-commit / False-on-abort, so the ownership answer existed and was being thrown away. It is now captured as was_ours and gates the removal, via a new _rollback_worktree_if_ours() helper. This deletes the unconditional force-removal rather than adding a check around it, and it makes the destructive decision directly unit-testable instead of only greppable (_unwind_create is a closure inside a ~200-line handler; this file has no behavioural harness for _handle_create, every other assertion about it is inspect.getsource). On a False pop the worktree is left in place with a warning — an orphaned worktree is recoverable by hand, deleted work is not.

Scope — deliberately only this one of four _remove_worktree call sites. The three earlier rollbacks (worktree_unusable, the _prepare_spec_dir refusal, and the _insert 409) all run before any index entry of ours exists, and _create_worktree would itself have failed had a concurrent create already made <repo>-wt-<name> — so the request that holds created_worktree demonstrably made it. Gating those would orphan a worktree on every legitimate 400/409. test_only_the_post_insert_unwind_needs_the_gate pins that count at 3 and fails if a fourth early rollback appears, so the audit is forced rather than assumed.

Revert-verified — three mutations, each syntactically valid, each failing a NAMED test:

Reverted Named failure
gate removed from the helper (always force-remove) test_rollback_spares_a_replacements_worktree
unwind bypasses the helper, calls _remove_worktree directly test_unwind_gates_the_rollback_on_the_pinned_pop
unwind hardcodes was_ours=True instead of passing the pop result test_unwind_gates_the_rollback_on_the_pinned_pop

Two of the three new tests are behavioural (they call _rollback_worktree_if_ours with a _remove_worktree probe and assert on what would have been destroyed), not source greps. The wiring guard asserts the pop precedes the rollback, that was_ours=was_ours is actually passed, and that no raw _remove_worktree( survives inside the unwind to bypass the gate. test_remove_worktree_is_destructive_enough_to_need_the_gate pins why the gate matters, so if _remove_worktree ever stops being a force-remove + branch -D the gate can be re-argued rather than cargo-culted.

Gates: pytest 23651 passed; the 9 failures are the 8 documented pre-existing ones plus test_apps_registry::test_list_registry_reaps_detect_probe_tree_on_timeout, which is order-dependent and passes 8/8 in isolation. isort/flake8 clean; mypy: Success: no issues found in 585 source files. Spec Builder's own suite: 237 passed.

Frontend gates are reused from 07d8d8ea (tsc -b clean, eslint 0 errors, i18n 1354/1840, vitest 578 pass): rounds 66 and 67 are backend + tests only, and the base has not moved since that run.

Screenshot URLs re-pinned to a83611bd8 (8 of 8).

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 68 — three findings fixed, and the first round where the local review gate ran before the push rather than after it.

Head is now 720a4d96a (was a83611bd8, then 45da0ca4 mid-round).

1. BLOCKING — routes.py:1139 — "Slot identity is recomputed after an await" → FIXED

Legitimate. _slot_key(name) reads the module-global _SLOT_KEYS, which a delete + same-name recreate rewrites to a fresh per-creation key — and _ensure_worker_slot resolved it four times, including after await _restore_worker_transcript.

There were two windows, not one. Beyond the restore await the finding named, slot.project / slot._app are stamped after safe_wd = await asyncio.to_thread(_safe_dir, wd), so a replacement could be repointed at the stale request's directory.

Fix: resolve the key once before any await, use that local throughout (including get_or_create_slot), and re-assert it after each await via a new _slot_identity_moved(name, slot_key) guard that audits spec_slot_replaced_midflight and refuses. Four mutations revert-verified, each failing a named test; a source guard asserts _slot_key(name) appears exactly once in the function and that each await is followed by a re-check.

2. BLOCKING (found locally, pre-push) — routes.py:1390 + :2853 — directory-only identity pins → FIXED

Both sites pinned on spec_dir alone, violating a rule this file already states in _unwind_create: "a delete followed by a re-import at the same name AND path leaves spec_dir identical, so the directory alone cannot tell our insert from the replacement's."

  • _effective_status_touch_spec(..., status="planning"): now passes expect_slot_key from the same snapshot the caller validated. _touch_spec has accepted that parameter since round 62; this call simply omitted it.
  • _handle_handoff: captures started_slot_key before the _prepare_handoff await and requires it in the reread. The pre-existing slot_key check there only validated the client's claim, so a request carrying no claim had no identity check at all.

Worth recording: the local Opus reviewer examined the _effective_status site independently and dismissed it, reasoning the stamp can only run when neither a nudge loop nor a turn is live. That falsification is incomplete. A replacement mid-arming has written status=executing but not yet armed its loop, so _exec_loop_active is False and no turn is running — and the arming grace cannot rescue it, because exec_arming_at is read from the stale meta (the caller's snapshot of the original spec), not the replacement's fresh entry. All three guards fall through. test_reconcile_stamp_survives_the_arming_window pins exactly that path.

Three mutations revert-verified (including one that only moves the capture to after the await it must survive). A class guard, test_no_index_mutation_is_pinned_on_the_directory_alone, now walks every _touch_spec( call and fails if one pins expect_spec_dir without expect_slot_key — rounds 62 and 68 were both a caller passing half the identity, so the invariant is now enforced rather than remembered.

3. Advisory (local Opus) — SpecBuilderPage.tsx:30 — client allowlist wider than the manifest → FIXED

CHAT_API_PATHS granted /api/approvals, which (a) nothing uses, (b) app.json's permissions.api does not declare, and (c) ChatEmbed deliberately avoids — approvals route through POST /api/chat/slots/{slot}/approve because /api/approvals/{id}/{action} takes only approve|reject and would silently downgrade a Trust click. ChatEmbed.test.tsx:496 already asserts /api/approvals/ is never posted to. Dropped the grant and corrected the comment, which asserted the opposite.

Rebase — autonudge.py removed from this diff entirely

The rebase (23 commits) conflicted in src/kiro_crew/autonudge.py. Main has independently shipped the same fix this PR carried there — non-blocking save plus drain-on-cancel (#425) — so I took main's side and dropped my change, including the _write_payload_locked helper. Its test asserted on that helper's name; it now asserts the property (persist=False, run_in_executor, CancelledError, and no time-bounded drain) and passes against main's implementation. The two behavioural tests in test/test_autonudge.py are kept: main covers add/update/post-fire but has no test for remove().

Not absorbed: main's remove() drains with a single await asyncio.shield(fut) where mine looped while not fut.done(), so a second cancellation during the drain can still unwind early. That is main's code and belongs in its own PR.

Gates

  • pytest: 23776 passed, 29 failed — none from this diff. 20 are test_mcp_gateway_shutdown_and_env (main's PoolKey channel_id breakage, reproduced on a pristine origin/main worktree), 3× test_embedding_space_reconcile, 3× test_dashboard_origin, test_browser_recording_skill, test_app_manager::test_declares_backend_helper, and test_terminal_handler (order-dependent, passes in isolation).
  • isort / flake8 clean; mypy: Success: no issues found in 587 source files.
  • tsc -b clean; eslint 0 errors; I18N_BASE_REF=origin/main npm run i18n:check: OK: 1349/1840, 0 newly-untranslated.
  • vitest: 7110 passed, 1 failed — src/i18n/format.test.ts, which asserts the absence of Intl.DurationFormat; local Node 24.18 has it, CI's does not.
  • Spec Builder's own suite: 242 passed. config-baseline.json regenerated with main's stt.endpointing key during test runs and was restored, not committed — that drift is main's.

Screenshots re-pinned to 720a4d96a (8 of 8).

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Rebased onto main to clear the merge conflict. Head is now eed0e1132 (was 720a4d96a), single commit, 0 behind.

25 commits of main absorbed, 18 conflicts, all resolved. Sixteen were mechanical unions; two were judgment calls worth recording.

Two decisions a reviewer shouldn't have to infer

1. useColumnResize — dropped this branch's copy, took main's. This branch created website/src/hooks/useColumnResize.ts as a new extraction so the Spec Builder rail could share Issue Radar's resize behaviour. main has since made the same extraction independently, and additionally moved ResizeHandle.tsx from src/apps/issue-radar/components/ to the shared src/components/.

Main's version exports an identical public API — CollapseConfig{width,storageKey,slop?}, ColumnResize{width,collapsed,dragging,expand,nudge,handleProps}, and the same six-parameter useColumnResize(storageKey, load, min, max, collapse?, loadCollapsed?) signature — and is more evolved (183 lines vs 162). So main's is canonical and this branch's consumer still compiles unchanged: components/Workspace.tsx already imports from the shared ../../../hooks/useColumnResize, and the Spec Builder never imports ResizeHandle directly. test/IssueRadarColumnResize.test.tsx therefore takes main's import path, which is the one that resolves post-rebase.

This is the second round in a row where main independently shipped something this branch carried (round 68 was autonudge.py / #425). Both times the branch's copy was dropped rather than merged.

2. Reverted an out-of-scope i18n register change. The resolver flagged that this branch had modified components.inboundLinkChip.confirm_release in de.json and fr.json — switching German Du kannstSie können and French Tu peuxVous pouvez. Both the merge base and main use the informal register, so this branch was the outlier, the key belongs to no part of this feature, and the repo has previously rejected a formal-register suggestion. Main's wording is restored in both catalogs.

An audit script now checks every catalog for keys this PR changed outside its own namespaces (apps.specBuilder, appSdk.chatMessageList) and asserts no key main has was dropped. Those two were the only strays.

The mechanical sixteen

  • Additive registrations (main added a new builtin where this branch adds spec_builder): apps/builtins/__init__.py (BUILTIN_NAMES — both papyrus and spec_builder, sorted), security_posture.py (both audited route files), builtinRegistry.ts (both /projects and /spec-builder), eslint.i18n.config.js (both exemptions, each keeping its own rationale).
  • 10 locale catalogs resolved by taking main's file wholesale and re-applying only the ~107 keys this branch added under apps.specBuilder / appSdk.chatMessageList. No collisions with main.
  • pluralKeys.json is a sorted list, so it was resolved as a union: main's 69 keys plus this branch's one, re-sorted. Main's own removal of components.pixelCanvasWidget.agent was respected rather than resurrected.
  • en-XA.json regenerated via node scripts/gen-pseudolocale.mjs, never hand-merged. Verifies as matches 5917 English keys.

One resolution error was caught by the gates and fixed: en.context.json's entries is a flat map of dotted-key → description string, but the generic locale resolver flattens and re-nests, so it turned entries["apps.specBuilder.components.projectPicker.up"] into nested objects. That produced an orphan top-level apps entry and a description.trim is not a function failure in contextSidecar.test.ts. Rebuilt as flat dotted keys; all 7 of that file's tests pass.

Gates on eed0e1132

  • pytest: 24416 passed, 9 failed — 8 are the documented pre-existing set (3× test_embedding_space_reconcile, 3× test_dashboard_origin, test_browser_recording_skill, test_app_manager::test_declares_backend_helper), and test_acp_runtime::test_n_sessions_routed_independently is the known ACP sleep-race flake class, passing 168/168 in isolation. The 20 test_mcp_gateway_shutdown_and_env failures present last round are gone, since fix(test): drop removed channel_id field from PoolKey test helper #1201 landed on main.
  • isort / flake8 clean. mypy: Success: no issues found in 597 source files.
  • tsc -b clean. eslint 0 errors, 317 warnings. I18N_BASE_REF=origin/main npm run i18n:check: all four gates OK, 1324/1837, 0 newly-untranslated.
  • vitest: 7464 passed, 1 failed — src/i18n/format.test.ts, which asserts the absence of Intl.DurationFormat; local Node 24.18 has it, CI's does not.

Screenshots re-pinned to eed0e1132.

Note for context: this PR had reached readiness: passed on 720a4d96a at 20:27 UTC, with all five reviewers green (including the Arbiter, once its duplicate design-review comment was removed and Opus 5's re-run succeeded in 42m49s). This rebase resets those verdicts; the diff itself is unchanged apart from the resolutions above.

@iamwhatever

Copy link
Copy Markdown
Collaborator

merge conflicts

bolichen97
bolichen97 previously approved these changes Aug 3, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Rebase onto main (48 commits) — head 2eb8ba251

Two substantive resolutions, plus a main-wide gate this branch now has to satisfy.

1. chat_persistence.py — both sides had independently moved the rehydrate reads off the loop

main added _prefetched_meta / _prefetched_messages on the synchronous
_rehydrate_slot_from_history, filled by a new rehydrate_slot_from_history_async
(consumed by slack/gateway.py). This branch had built a parallel
_HistoryReads / _read_history_for_rehydrate / _apply_history_to_slot split driven by
arehydrate_slot_from_history.

Resolved in favour of main's mechanism, and the branch's four constructs are deleted.
My first attempt kept the branch's split and re-expressed main's entry point as a
delegation; test/test_rehydrate_async.py — main's own dedicated test file — falsified
that, because it asserts _rehydrate_slot_from_history actually receives the prefetch
kwargs, not merely that the reads land off-loop. Rewriting main's tests to fit a parallel
design in this module was the wrong direction, so the scaffolding went instead.

The branch's two genuine contributions are re-applied directly onto main's shape:

  • The rollback now lives in _rehydrate_slot_from_history. On main it lives in the
    caller (_restore_open_slots_steps pops the partial slot in its own except), so every
    other caller silently leaks a half-built slot and a stale restricted key on failure.
    Owning it at the creation site let the caller-side compensation be deleted rather than
    duplicated.
  • adopt_closed is threaded through both entry points. App-owned worker slots need it:
    idle-slot cleanup marks them closed without the user asking, and the app's own delete path
    is what should end them. Main's tab-close tombstone guard is preserved and deliberately
    skipped for adopt_closed callers, whose lifecycle belongs to the app.

Two tightenings fell out of main's shape:

  • preexisting_slot is dead here — main's function returns early when the slot is
    already in state._slots, so the body only ever runs for a slot this call created and the
    pop is unconditionally correct. Dropped.
  • preexisting_restricted is not dead: a restricted key can outlive its slot, so the
    call must not discard one it did not add. test_a_preexisting_slot_is_never_popped was
    unreachable through the public entry point and is replaced by
    test_a_preexisting_restricted_key_is_never_discarded, which pins the half that is
    reachable.

Revert-verified, each mutation left syntactically valid so the failure is a named test:

mutation named test that fails
rollback removed test_sync_rehydrate_leaves_no_partial_slot, test_async_rehydrate_leaves_no_partial_slot
preexisting_restricted guard dropped test_a_preexisting_restricted_key_is_never_discarded
adopt_closed ignored on the sync path the archived-session restore assertion

Main's 7 test_rehydrate_async.py tests pass unchanged.

2. i18n-strict:all-caps-const — main's #1099 (84b4ea0d0) landed in these 48 commits

The strict config now inspects literals inside ALL-CAPS module constants, which the
per-file ceilings never counted. Every line in a new file counts as written by this branch,
so all 20 such literals in the Spec Builder fired at once under two zero-tolerance gates.
They are three categories and are treated differently:

  • 14 are user-visible copy → catalog keys resolved with i18nT at the render site,
    mirroring PHASE_LABEL_KEY / phaseLabel() in code-review-sage, including its
    hasOwnProperty guard (the phase arrives on a backend payload, so toString would
    otherwise resolve to an inherited prototype member and hand a function to i18next).
    20 keys × 10 locales.
  • 2 are CSS values (color-mix(...)) → moved into inlineStyles.ts, already exempt by
    path for exactly this reason, and re-exported from shared.tsx so the five importers are
    untouched.
  • 2 are prompt text sent TO the agent (ADVANCE[].msg, consumed by
    messageMutation.mutate) → moved into prompts.ts, already exempt via main's
    src/apps/*/prompts.ts glob. Localising them would change the instruction the model
    receives per user locale.

No new eslint exemptions were added: both non-copy categories moved into modules that are
already exempt for the right reason. For the same reason this branch's explicit
src/apps/spec-builder/prompts.ts entry was dropped rather than unioned — main's glob
now covers it, and re-adding it would leave dead config.

Register is per namespace, not per repo: apps.specBuilder is uniformly formal in de
(16/0) and uniformly informal in fr (15/0), and each locale follows its own siblings.

Verification

26871 pytest passed; the 9 failures all reproduce on pristine origin/main
(3× test_embedding_space_reconcile, test_embedding_model_apply,
test_app_manager::test_declares_backend_helper, 3× test_dashboard_origin, and
test_browser_recording_skill which needs Node ≥18). 8290 vitest passed; the one failure
is src/i18n/format.test.ts, inherited — this host's Node 24 has Intl.DurationFormat
and the test asserts its absence. tsc -b, eslint (0 errors), isort, flake8, mypy
(637 files) and all 11 i18n checks clean.

One self-inflicted error caught before pushing: the first pass at the catalog edits
alphabetised every sibling group, ~1400 lines of churn per file. Beyond the noise it would
have been actively wrong — [added-lines] counts a moved line as newly written, so it would
have reported every re-ordered pre-existing untranslated literal as this branch's debt.
Redone as an in-place insert: 28 lines per catalog, 0 existing values changed, 0 keys lost.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 71 — head a356fd470

FIXED (accepted) — create accepts names its loader immediately discards

Confirmed reachable before fixing, not just read off the source. _NAME_RE is
^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$, which is exactly the shape of several credential
formats, so I probed the two predicates directly:

candidate _valid_name _usable_name outcome before the fix
AKIAIOSFODNN7EXAMPLE yes no created, then dropped by _load_index
ghp_ + 36 alnum yes no created, then dropped
xoxb-… yes no created, then dropped
my-normal-spec-name yes yes fine

3 of 7 shapes passed create and were discarded by the very next load, orphaning the
directory, worktree and session the handler had already built. Your diagnosis and your
remedy were both right.

Widened: the same class was in two more write-side paths

_load_index admits a key only when _usable_name(k) holds — the grammar and
survival of _redact unchanged. Three paths that put a name INTO the index gated on
_valid_name, the grammar half alone. You named one; the other two are the same bug:

  1. create (line 2426) — the site you found.
  2. _discover_folder_specs (line 2274) — writes index[name] for an adopted
    on-disk directory. A credential-shaped directory name was re-added on every list
    poll and dropped on every load, so it was rediscovered and re-saved indefinitely.
  3. _ensure_worker_slot's name re-assert (line 1144) — its own comment claimed it
    "re-assert[s] the same grammar creation and discovery enforce", which would have
    become false the moment the other two moved.

All three now use _usable_name, so the index has exactly one admission predicate and
the write side cannot disagree with the read side.

Deliberately not changed: _owns_slot_key (line 760). Its name always originates
from an index key that already passed _usable_name, so the two predicates agree there,
and it carries legacy slot-key compatibility worth leaving alone. The class guard
allowlists it by name with that reasoning.

The error message now says the name "must not look like a credential" alongside the
grammar. code stays invalid_name — same category, and it avoids churning the
enumerated error-code baseline for a message change.

Tests — revert-verified, each mutation left syntactically valid so a NAMED test fails

mutation named test that fails
create back on the grammar half test_create_refuses_a_name_the_loader_would_discard
discovery back on the grammar half test_discovery_does_not_adopt_a_name_the_loader_would_discard
slot re-assert back on the grammar half test_no_index_write_path_admits_on_the_grammar_alone

Four tests: two behavioural (create returns 400 and leaves no spec directory; discovery
adopts the two ordinary siblings and refuses the credential-shaped one), one fixture
guard (test_the_probe_name_is_the_shape_this_class_is_about pins that the probe is
grammar-valid but not admissible, so the others cannot pass for the wrong reason), and
one class guard that walks every function in routes.py and fails if any but the
allowlisted _owns_slot_key gates on _valid_name. That last one is the one that keeps
a future caller from reintroducing the split — it asserts on the calls, not on a comment.

Verification

26871 pytest passed; the 9 failures all reproduce on pristine origin/main
(3× test_embedding_space_reconcile, test_embedding_model_apply,
test_app_manager::test_declares_backend_helper, 3× test_dashboard_origin,
test_browser_recording_skill needing Node ≥18). 250/250 Spec Builder tests pass.
isort, flake8 and mypy (637 files) clean. Backend-only change, so the frontend
gates are unaffected from 2eb8ba251, where they were green.

Not rebased: main has moved 3 commits but the PR is not CONFLICTING and the branch
protection does not require up-to-date, so rebasing would only restart the hour-long
gates.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 73 — head 15bb990f6 (prior reviewed SHA cc7f19df4)

FIXED (accepted) — BLOCKING: relative working directories pass validation

Correct, and the mechanism is worse than "a missing check": the check was there and
was dead
.

resolved = Path(os.path.realpath(os.path.expanduser(raw.strip())))
if not resolved.is_absolute():      # can never be False
    return None

os.path.realpath resolves a relative value against the gateway's own cwd and
always returns an absolute path, so the test that followed it was unreachable and
the "must be absolute" guarantee in _safe_dir's own docstring was not enforced at
all. With index.json agent-writable (_load_index treats it as untrusted for exactly
this reason), a working_dir of "." normalised to the gateway checkout and the spec's
worktree — and the agent running in it — were pointed at that tree.

The test moves to the expanded input, before realpath, which is the only place it
can mean anything, and the docstring now says where and why. This deletes a false
guarantee rather than layering a second check on top of it.

Checked for the same class across the module: this was the only instance. The other
absoluteness tests (_touch_spec's spec_dir.is_absolute(), api_browse_dirs' base,
and the create handler's working_dir) all run on the value before any
normalisation, so they are live.

FIXED — FINDING: capture-spec-builder.mjs state path

Also correct. _state_dir() is config_dir() / "workspace" / APP_NAME, so the index
lives at <home>/workspace/spec-builder/index.json; the script derived
<home>/spec-builder/index.json. That path never existed, so the move-aside silently
no-opped and every "first run" capture was actually taken against a populated index.
Inserted the workspace segment and recorded the backend function it has to mirror.

Tests — revert-verified, mutations left syntactically valid so NAMED tests fail

mutation named test that fails
guard removed test_safe_dir_refuses_a_relative_working_dir, test_absoluteness_is_checked_before_realpath
guard moved back AFTER realpath (the original dead-code shape) both of the above

Three tests: the rejection itself over ., .., relative/path, ./sub and ""
with the cwd moved into a tmp dir; a companion that pins the two legitimately-absolute
forms (plain absolute, and ~/…) still pass, so the guard cannot be "fixed" by
rejecting everything; and a source guard asserting os.path.isabs appears before
os.path.realpath, because the ordering is the entire defect and a comment cannot
enforce it.

Verification

27604 pytest passed; the 9 failures all reproduce on pristine origin/main. One run
showed a 10th failure that did not reproduce on an immediate re-run (27603 vs 27604
passed) — a flake, and outside this diff's reach either way, which touches only
_safe_dir and a Node capture script. 253/253 Spec Builder tests. tsc -b, eslint
(0 errors), node --check on the script, isort, flake8, mypy (661 files) clean.

Not this PR: De-Amazon Scrub Lint is failing main-wide

That check is red on this head, and none of it is this branch's:

src/kiro_crew/artifact_source.py:28
src/kiro_crew/artifacts.py:356
src/kiro_crew/artifacts.py:1070
test/test_artifact_source.py:299

All four occurrences are present on pristine origin/main, and none of those files
appear in this PR's diff
. Fixing them here would mean absorbing an unrelated main-wide
breakage into a feature PR, so it belongs in its own change. Flagging rather than
silently carrying it, because it will hold PR Readiness red for every open PR until it
is fixed at the source.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 74 — head 632c72df0 (prior reviewed SHA 15bb990f6)

FIXED (accepted) — BLOCKING: stale execute clears the replacement's stop sentinel before identity validation

Correct. _prepare_handoff calls _arm_stop_sentinel, and arming means removing the
STOP a Pause wrote
— so the destructive act ran before _client_identity_mismatch had
any chance to refuse. A stale same-name, same-path execute therefore disarmed a
replacement's Pause, and the persisted loop resumed editing after a restart.

Your remedy is applied, and it is the ordering the sibling handler already documents:
_handle_stop_execution opens with "Parse the body FIRST" for exactly this reason, so
the two handlers now agree instead of disagreeing.

One addition, because ordering alone leaves a hole. _client_identity_mismatch
compares only what the CLIENT sent, and returns False when the request carries no claim
— by design, so older tabs keep working. A claimless stale execute would therefore pass
the relocated check and still clear the sentinel. So the clear itself is now gated:
_prepare_handoff re-reads the index under _INDEX_LOCK and refuses unless the spec
still carries the slot_key this request started with. The destructive act is
conditional on identity rather than merely well-ordered — the same shape as
_rollback_worktree_if_ours from the earlier stale-unwind round.

Both remain necessary: the claim check refuses a mismatched claim before any filesystem
work, and the gate covers the claimless case. The existing post-await reread is
untouched — a recreate can still land during the thread hop, and that guard is what
protects slot acquisition.

Unpinned specs (no slot_key, predating per-creation keys) skip the gate rather than
being refused, so legacy handoffs keep working; test_prepare_handoff_unpinned_call_keeps_working
pins that.

Tests — revert-verified

mutation named test that fails
identity gate removed from the clear (claimless hole reopens) test_prepare_handoff_refuses_to_clear_when_the_identity_moved
claim check physically moved back after the clear test_handoff_captures_its_identity_before_the_await_and_pins_on_both

Worth recording how the second was verified: neutering the check in place
(if False and …) did not fail the guard, because the guard tests ORDER and the text
was still present. That is the guard behaving correctly and my first mutation being
unrepresentative, so it was redone as a physical move of the block — which fails, with
the message "moved after the sentinel clear it is meant to gate".

Three new behavioural tests (refuse-on-moved-identity, still-clears-for-the-matching-identity,
unpinned-still-works) plus repairs to four existing tests the reordering touched:

  • Two source guards anchored on "asyncio.to_thread(_prepare_handoff" as a single-line
    literal, which the wrapped call broke. They are now whitespace-insensitive regexes —
    a guard a reformat can silently disarm is not a guard.
  • The order guard additionally pins the pre-clear claim check, so the new invariant is
    enforced and not just the old one.
  • test_no_handler_reads_the_index_on_the_event_loop gains _prepare_handoff, which
    now reads the index synchronously. That is legitimate under the guard's own stated
    rule — it only ever runs via asyncio.to_thread — but rather than just appending a
    name I tied the allowance to the contract: the test now asserts each off-loop
    allowee's docstring still declares BLOCKING, so the allowlist cannot be widened to
    admit a function that actually runs on the loop.

Verification

27604 pytest passed; the 9 failures all reproduce on pristine origin/main.
256/256 Spec Builder tests. isort, flake8, mypy (661 files) clean. Backend-only,
so the frontend gates carry over from 15bb990f6, where tsc -b, eslint and all 11
i18n checks were green.

De-Amazon Scrub Lint is green again on this head — the /workplace/nrb references
I flagged last round as main-wide were fixed upstream, so that is no longer a blocker
for this PR.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 75 — head de6a30fe4 (prior reviewed SHA 632c72df0)

FIXED (accepted) — BLOCKING: queued content bypasses dashboard redaction

Correct, and the targeting was exact. _ChatSlot.append suppresses the global SSE
push only for role not in ("chunk", "done", "user"), so "queued" — which this
handler uses — is broadcast, and the caller's text went to every connected dashboard
client verbatim. Note the sibling slot.append("user", message) two lines below is
not affected, because user IS in that skip set; flagging only line 1848 matched
the host's behaviour precisely.

The host already settles how this pair of paths must behave, and it is stricter than
"don't broadcast": on its steer path it sanitizes the stored value
(redact_exfiltration_urlsredact_credentials) before appending, commenting that
raw content must never reach an external surface (security-controls), and broadcasts a
display-redacted copy; its queue path redacts before broadcast_ws("queue_push", …).

So rather than adding broadcast=False, the append routes through _redact — this
module's existing copy of that exact chain, which additionally fails closed when the
security module cannot be imported. That reuses the module's own chokepoint instead of
introducing a second mechanism, and leaves nothing raw in memory for a future reader.

One thing deliberately not redacted: slot.queue_append(message) still receives the
real text. The agent has to act on what the user actually typed; only the copy that
leaves the process is scrubbed. test_queued_append_is_redacted_before_it_is_broadcast
asserts both halves, so a future "fix" that redacts the queue too would fail.

slot.append("user", message) stays raw, matching the host's own send path
(chat_handlers.py:376) and the documented model — user never broadcasts, the author
is its only reader, and redaction happens at the emit sites. Redacting here would
diverge from the host and double-redact at emit.

Tests — revert-verified

mutation named tests that fail
queued append back to the raw message test_queued_append_is_redacted_before_it_is_broadcast, test_no_broadcast_eligible_append_passes_raw_caller_text

Three tests:

  • Behavioural — a credential-shaped token in a queued message must not survive into
    the appended (broadcast) content, while the queue still carries it intact.
  • Class guard — walks the AST for every slot.append(...) in the module and fails
    if any call whose role is NOT in the host's skip set passes content that did not go
    through _redact. That is what stops the next broadcast-eligible role from
    reintroducing this.
  • Fixture guard — asserts _ChatSlot.append still contains
    role not in ("chunk", "done", "user"). The rule above is derived from that skip
    set, so if the host changes it the guard fails loudly instead of silently protecting
    the wrong roles.

Verification

27604 pytest passed; the 9 failures all reproduce on pristine origin/main.
259/259 Spec Builder tests. isort, flake8, mypy (661 files) clean. Backend-only,
so the frontend gates carry over from 632c72df0.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Rebase onto main (15 commits) — head 25f6cd617

No new findings. The previous head de6a30fe4 reached readiness: passed with all five
reviewers green
(GPT 5.6, Opus 5, Design, UX, and the aggregate) and then went
CONFLICTING when main moved, so this is a rebase only — no code change beyond the
conflict resolution below.

One conflict: chat_persistence.py

Main added a linked_session_key restore block inside _rehydrate_slot_from_history,
in the same body this branch wraps in its rollback try:. Because the whole body is
re-indented on this side, git's hunk boundaries straddled a large region rather than the
five new lines, so patching the markers in place would have been guesswork.

Resolved deterministically instead: take main's file wholesale, then re-apply this
branch's two additions to it — adopt_closed threaded through both entry points, and the
rollback that _rehydrate_slot_from_history owns (with the caller-side compensation in
_restore_open_slots_steps deleted, and the unconditional pop justified by the function's
early return). That is the same procedure used when this branch first converged onto
main's shape, so main's _prefetched_meta / _prefetched_messages mechanism and its new
linked_session_key block are both preserved verbatim.

Verification after the rebase

27718 pytest passed. Ten failures, all accounted for:

  • the 9 that reproduce on pristine origin/main (3× test_embedding_space_reconcile,
    test_embedding_model_apply, test_app_manager::test_declares_backend_helper,
    test_dashboard_origin, test_browser_recording_skill needing Node ≥18);
  • test_terminal_handler::test_ws_ctrl_c_delivers_sigint@pty_integration, which is
    order-dependent under xdist — 167/167 pass when that file runs alone, so it is not
    a regression from this diff.

320/320 across test_rehydrate_async.py, test_session_restore.py and the Spec
Builder suite — the three that actually cover the rebuilt file. tsc -b clean, all 12
i18n checks pass (main added a twelfth), isort, flake8, mypy (663 files) clean.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 77 — head 11a9b03a2 (prior reviewed SHA 25f6cd617)

FIXED (accepted) — BLOCKING: agent-written settings bypass dashboard redaction

Correct, and the module's own code settles it rather than requiring a judgement call:

  • _load_settings's docstring declares the file untrusted in as many words — "a
    hand-edited (or agent-edited) settings.json" — and it validates the file's
    SHAPE, not its CONTENT. So a credential parked in base_path survives loading
    intact.
  • The list endpoint already wraps every stored field it returns:
    _redact(str(meta.get("working_dir", ""))), and the same for spec_dir and
    spec_type. Redaction at the egress is therefore this module's established
    convention
    , not a new mechanism.

_handle_get_settings was the single response that omitted it, so your remedy is exactly
the convention the siblings already follow. Applied verbatim.

Scope checked, and deliberately narrow. There are three _load_settings() consumers.
The other two — _resolve_spec_dir and the containment check in the spec-dir resolver —
use the value to build or validate a path, never to answer a request. Redacting there
would corrupt the path and break spec resolution, so they are correctly left alone. That
asymmetry is why the class guard keys on "handler that reads settings and returns a
response
" rather than on "reads settings".

Tests — revert-verified

mutation named tests that fail
settings egress back to the raw value test_get_settings_redacts_an_agent_written_base_path, test_every_handler_that_returns_settings_redacts_it

Three tests:

  • Behavioural — a credential-shaped token written into base_path via
    _save_settings must not appear in the GET /settings response.
  • A companion that pins the ordinary path unchanged. This one matters: redaction here
    must not mangle a normal path, or the picker would display a scrubbed value and a
    round-trip save would corrupt the setting. Without it, "redact everything" would pass
    the first test while breaking the feature.
  • Class guard — walks every _handle_* in the module and fails if one reads
    _load_settings and returns a json_response without _redact.

On the re-review

This diff passed GPT on de6a30fe4 and this finding appeared only after the rebase to
25f6cd617, whose only delta was the chat_persistence.py conflict resolution — nothing
touching _handle_get_settings. So the finding was latent and surfaced on a fresh roll
rather than being introduced. It is legitimate either way; noting it only so the history
is not read as a regression.

Verification

27718 pytest passed. Ten failures, all accounted for: the 9 that reproduce on pristine
origin/main, plus test_terminal_handler::test_ws_ctrl_c_delivers_sigint@pty_integration,
which is order-dependent under xdist and passes 167/167 when that file runs alone.
262/262 Spec Builder tests. isort, flake8, mypy (663 files) clean. Backend-only, so
the frontend gates carry over from 25f6cd617 where tsc -b and all 12 i18n checks were
green.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 78 — head 5e33527e1 (prior reviewed SHA 11a9b03a2)

FIXED (accepted) — BLOCKING: failed settings load permits destructive save

Correct, and it is data loss rather than a cosmetic gap. basePath starts as '' and is
only seeded once settingsQuery.data arrives, while Save was guarded by busy alone —
the save mutation being in flight. So during the read, or after it failed, Save was
enabled and posted the empty buffer over a configured base_path.

Applied your remedy: Save is now disabled while the read is pending or errored.

One addition. A Save button disabled for no visible reason is its own defect, so a
failed read is now reported through the existing page-level setErr prop the
component already accepts. That gives the user the actual thrown message and needs no new
copy across ten locales — the alternative (a new inline error string) would have meant 10
catalog edits plus an en-XA regen for a message the caller can already render.

Scope checked: SettingsModal is the only component in this app with an edit buffer
seeded from a query. SpecDetail's mutations are actions (stop / execute) guarded by
their own isPending, not writes of a possibly-unseeded buffer, so nothing else needed
changing.

Tests — revert-verified, and the third test is the interesting one

New website/src/test/SpecBuilderSettingsSaveGuard.test.tsx, 3 tests, all three fail when
the guard is reverted to disabled={busy}:

  1. Save is disabled while the read is pending (a promise that never settles).
  2. Save stays disabled after the read fails, setErr receives the message, and
    saveSettings is never called.
  3. Save becomes enabled once the read lands, with the stored path already seeded.

Test 3 deserves a note, because its pre-fix failure is the clearest statement of the bug.
Reverted, waitFor(Save enabled) resolves immediately — Save is enabled from the
first render, before any data arrives — so the subsequent seeding assertion runs too early
and fails with "Unable to find an element with the display value: /srv/specs". That
sequencing is the vulnerability: the control becomes clickable before the buffer holds
the stored value.

I initially mis-mutated this one (removing the unloaded use but leaving the now-unused
const, which broke compilation and failed all three for the wrong reason). Redone as an
exact revert to the pre-fix shape, confirmed by git diff showing only the intended
lines, so the failures are behavioural.

Verification

tsc -b clean, eslint 0 errors, all 12 i18n checks pass, 8632 vitest passed — the
single failure is src/i18n/format.test.ts, inherited (this host's Node 24 has
Intl.DurationFormat; the test asserts its absence). Frontend-only change, so the backend
gates carry over from 11a9b03a2: 27718 pytest with the 9 origin/main failures plus the
order-dependent test_terminal_handler pty test (167/167 alone).

Not this PR: Backend Tests (Windows) (1)

That shard failed on 11a9b03a2 with
test_acp_runtime.py::TestAcpRuntimePidTracking::test_kill_untracks_pid — assert [] == [4242].
test_acp_runtime is not in this PR's diff, the test passes locally, shards 2–4 were
green, and main has touched acp_runtime twice recently (#1307, #1212). A targeted re-run
was refused while the workflow was still live, so this push gives it a fresh Windows run;
if it reproduces on the new head I will chase it as a main-side Windows issue rather than
absorbing it here.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 79 disposition — reviewed SHA 2bf1b57bf

Accepted. The finding is correct and the mechanism is exactly as described: _mark_deleting persists the reservation before the teardown and _unmark_deleting clears it after, so any hard exit in that window leaves the marker with no request alive to release it. _load_index then keeps returning the entry with deleting set, which hides the spec from the list and holds its name against a re-create — permanently, with no self-healing path.

Applied your remedy: process identity + release on load.

  • _PROCESS_ID (pid:uuid4) identifies this gateway process. The uuid is what makes the comparison sound rather than the PID — PIDs are reused across boots, so a recycled PID would otherwise read as still-ours and the reservation would survive exactly the crash it needs to be cleared by.
  • _mark_deleting now stores {"owner": _PROCESS_ID, "at": ...} instead of a bare timestamp.
  • _load_index releases any reservation this process does not own.

Two scope decisions worth stating.

The release writes nothing. _load_index is the read half of _mutate_index (which is _load_index → mutate → _save_index), so stripping the field from the returned copy means the next mutation persists the cleanup for free, and until then the entry is simply visible again. Adding I/O to the read chokepoint would have put a write on every list poll — and _load_index is called from a worker thread on paths that must not write.

Own reservations are left strictly alone. A blanket "clear all reservations on load" would be wrong in a way worth naming: a delete in flight re-reads the index on every _mutate_index hop, so it would cancel its own reservation underneath itself and re-open the same-name window the reservation exists to close. Ownership is what separates the two cases, which is why the marker carries identity rather than just an age.

On the tombstone half of your remedy. I did not clear tombstones, and I want to be explicit that this is a judgement rather than an oversight. _remember_deleted is written before the reservation, so it is also stale after the same crash — but it is inert once the entry is visible: the tombstone is consulted only by _discover_folder_specs, which considers directories absent from the index, and this entry is present. So it suppresses nothing the user can observe, and the next real delete rewrites it. Clearing it would need a second write on the read path for no behavioural gain. If you read a path where a stale tombstone is observable with the entry present, name it and I will fix that too.

Tests — 4, revert-verified against the exact pre-fix shape (restored via git checkout HEAD --, so the mutation is the real regression rather than a half-removed fix):

test pre-fix
..._releases_a_reservation_left_by_a_dead_process fails — "still hides the spec and reserves its name"
..._releases_a_legacy_bare_timestamp_reservation fails — pre-upgrade bare-float marker not released
..._keeps_a_reservation_this_process_still_owns passes (ordinary case, guards the blanket-clear regression)
..._released_reservation_is_persisted_by_the_next_mutation fails — stale marker still on disk after a mutation

The third is deliberately the one that passes both before and after: it exists to fail if someone later "simplifies" this into clearing every reservation. My first attempt at it asserted on the new helper and so failed pre-fix with AttributeError — the wrong reason — so I cut that assertion and left it purely behavioural.

The legacy case is a real migration path, not a hypothetical: an index written by the previous build stores a bare float, which carries no owner and therefore reads as foreign. That is the correct answer, since this process demonstrably did not write it.

Also in this push (unrelated to your finding): the Brand Name Gate landed on main while this branch was idle and flagged 3 prose lines spelling KiroCrew rather than Kiro Crew; fixed, and the gate's own self-test plus the diff check now pass locally. The rebase carried 16 commits of main, resolving chat_persistence.py (main evolved the same function this branch wraps in its rollback) plus the ten locale catalogs.

Not this PR's: Backend Tests (Windows) (3) failed on test_mochi_mcp_server.py::TestQueueMutationLock::test_concurrent_appends_are_not_lost (assert 15 == 16) — a concurrency test for main's own mochi app, in no file this diff touches.

Verification: 30,045 pytest pass; isort/flake8 clean; mypy 715 files clean; tsc -b clean; eslint 0 errors; the full 12-check i18n chain green; brand gate green; 8,936 vitest pass with two known-inherited failures (format.test.ts, which asserts the absence of Intl.DurationFormat that this host's Node 24 has, and the load-sensitive WindowCalculator benchmark, 23/23 in isolation — neither subject is in this diff).

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Round 80 disposition — reviewed SHA ae09a84d6

Accepted, and applied your remedy as stated: _INDEX_LOCK now spans the identity check and the sentinel mutation.

You were right that ordering alone does not close this. R74 moved the identity check ahead of the arm; R80 is the window that survives correct ordering, because the check released the lock before the act. A same-name delete plus re-import landing in that gap leaves the check passing for a spec that is already gone while the arm lands on its replacement, clearing a STOP the user's Pause had just written.

    with _INDEX_LOCK:
        if name and expect_slot_key:
            current = _load_index().get(name) or {}
            if str(current.get("slot_key", "")) != expect_slot_key:
                return False, ""
        sentinel = _arm_stop_sentinel(spec_dir)

Why holding the lock across filesystem work is safe here — and why I checked rather than assumed. This repo has a hard rule against index work on the event loop, and a previous PR oscillated between "sync write blocks the loop" and "async write loses updates", so widening a critical section around I/O deserved proof, not confidence:

  • _prepare_handoff is BLOCKING by contract and only ever reached through asyncio.to_thread, so the section cannot stall the loop. The suite's own test_no_handler_reads_the_index_on_the_event_loop ties that allowance to the docstring, so it cannot be widened later to admit a function that really runs on the loop.
  • _INDEX_LOCK is a plain, non-reentrant threading.Lock, so a re-acquisition anywhere under _arm_stop_sentinel would deadlock rather than fail a test. I walked the call graph with AST: seven functions acquire it (_remember_deleted, _forget_deleted, _aload_index, _mutate_index, _prepare_handoff, _load_index_with_discovery, _read) and none is reachable from _arm_stop_sentinel / _verified_spec_dir / _clear_stop_sentinel / _spec_file. The docstring now records that constraint so the next widening has to re-establish it.

I extended the fix to the write, which your finding also quoted. _halt_execution sent _write_stop_sentinel through asyncio.to_thread after the caller's identity check — and a thread hop is exactly the window you described. Creating a STOP is as destructive as removing one: a stale Stop halts a run the user has only just started under the reused name. New _write_stop_sentinel_for_spec does the check and the write in one _INDEX_LOCK hold, off-loop, and _handle_stop_execution passes the captured_slot_key it already verified. Callers with no identity to pin still get the plain write, since the gate cannot refuse what it cannot identify.

Four tests, revert-verified against the exact pre-fix shape (restored with git checkout HEAD -- <file>, so the mutation is the real regression and not a half-removed fix):

test pre-fix result
..._arms_under_the_same_lock_as_the_identity_check fails — "arm is outside the _INDEX_LOCK block"
..._halt_execution_writes_the_sentinel_off_the_loop fails — write not on the identity-pinned wrapper
..._stop_write_is_refused_for_a_replaced_spec AttributeError (subject does not exist pre-fix)
..._no_handler_reads_the_index_on_the_event_loop AttributeError (same)

Being straight about the last two: they fail for the wrong reason pre-fix, because a test of a new function cannot run before the function exists. That is unavoidable, not a claim of behavioural coverage. The ordinary case stays pinned by the pre-existing handoff tests, which pass unchanged before and after. The first test is structural on purpose — the race is a thread interleaving, so timing it would be flaky, while the property that forbids it (the arm sits inside the locked block, and nowhere outside it) is exactly stated in the source.

I also made the off-loop guard whitespace-insensitive. It matched "asyncio.to_thread(_write_stop_sentinel" as a single-line literal, which my multi-line call broke — a guard a reformat can silently disarm is not a guard, and its sibling test_handoff_does_no_filesystem_work_on_the_loop had already learned this.

Separately in this push: 14 Windows failures in this suite, now recorded as a tracked platform boundary. Worth stating plainly because it is not a paper-over. _CAN_PIN_DIR is False on Windows (no O_NOFOLLOW, no dir_fd), so _write_stop_sentinel and _clear_stop_sentinel fail closed by design — they refuse to operate by path, exactly as the source comment explains, because the agent could swap the directory for a junction between check and write. The failing tests assert the pinned POSIX behaviour, so on Windows they assert a capability the product deliberately declines to have.

A new tests/conftest.py skips them by name on Windows, mirroring test/conftest.py's burn-down convention (whose hook is rooted at test/ and so never covered this path). It is deliberately not a whole-file collect_ignore: simulating Windows locally gives 253 passed, 15 skipped of 268, so Windows keeps almost all of this suite, including the coverage of its own no-sentinel halt path. Anything unlisted still fails the job, and I verified every listed name resolves to a real test so a typo cannot silently protect nothing.

Why it surfaced only now, since these tests are byte-identical to the previous head: CI has no committed .test_durations, so pytest-split falls back to an even split by test count. The rebase over 16 main commits changed the suite size, moving whole blocks between shards — Windows shard 4 went from 6,512 tests to 6,974. This was latent debt any rebase would eventually expose.

Verification: 30,048 pytest pass with the same 7 pre-existing failures as the previous head (5 documented inherited, plus 2 in test_weixin_qr that are an artifact of the venv I borrowed lacking qrcode — it is a declared dependency in setup.cfg, so CI has it). isort / flake8 clean; mypy clean across 716 files. Backend-only change, so the frontend gates carry over from 2bf1b57bf, where tsc, eslint, the 12-check i18n chain and the brand gate were all green.

Ports the external kiro-specs app into a native builtin: backend routes with
_require_enabled gating, a TSX page suite, and the spec-workflow skill.

Adheres to the frontend style guide rather than re-implementing dashboard UI.
Every fix below deleted code instead of adding it:

- shared SegmentedControl for the doc tabs (the per-file status dot rides its
  icon slot, and responsive collapse comes free) — replaces two hand-rolled
  button rows
- shared Modal for settings — supplies role=dialog, aria-modal, Escape and
  backdrop dismissal, scroll lock and a labelled close button
- shared Btn / Input / SearchInput / EmptyState / Clickable throughout; all 11
  raw <button> elements are gone, and the local pill button is now a thin
  wrapper over the host Btn instead of 18 hand-written style props
- lucide-inline icons: dropped all 26 forbidden size={N} props, the inline-flex
  IconText wrapper, and the text glyphs used as icons
- Framer Motion pulse instead of a CSS @Keyframes block
- design system variables only; typography normalised onto the 14/13/12/11
  scale, including one 9.5px value below the hard floor
- aria-labels on every icon-only control, aria-live on the streaming/working
  regions, role=alert on the error banner, keyboard-operable spec rows, and an
  APG window-splitter (role=separator + arrow-key resize) for the divider

Gates: tsc clean, eslint 0 errors 0 warnings on the app (was 6 warnings),
vitest 4554 passed, backend 20 passed, flake8 + isort clean. Four new
accessibility regression tests, the strictest one revert-verified.

Security: adds a single _safe_dir() chokepoint for every caller-supplied
directory (working_dir, settings base_path, browse path). Previously only the
browse endpoint applied the sensitive-path test, so a direct create call could
name a credential directory as its working_dir and get a spec tree — and an
agent cwd — inside it. The chokepoint expands ~, resolves symlinks BEFORE
checking (so a planted symlink can't smuggle its target), requires absolute,
and denies is_sensitive_path; with must_exist=False it also validates the
nearest existing ancestor so a not-yet-created dir under a credential
directory can't slip through on a stat miss. Resolved spec dirs additionally
get an explicit _contained() assertion instead of relying on the name regex
defined three functions away. 7 new security tests, revert-verified.

CX alignment with the Issue Radar builtin (the reference triage app), so an
embedded builtin does not invent its own conventions:
- loading is a SKELETON THAT HOLDS THE LAYOUT, not a spinner: shimmer rows in
  the rail and a document-shaped skeleton in the doc pane, reusing the shared
  animate-shimmer utility. The sr-only role="status" sits OUTSIDE the
  aria-hidden subtree, or it would never be announced.
- the empty state is centred with an icon instead of a sentence pinned to the
  top-left, and no longer flashes before the first fetch resolves.
- the rail STAYS MOUNTED in every state and ends in app identity + version with
  Settings reachable from it. It previously unmounted on the empty state, so a
  first-run user could not reach settings at all — caught by the screenshot
  harness asserting the footer before it shot.
- detail mounts only for a spec present in the list: a stale localStorage
  selection used to surface a raw "not found" banner before the list reconciled.
- the splitter matches Issue Radar's (slim, hover-accent) and locks text
  selection while dragging.

Screenshots captured against a REAL isolated instance of this branch (own port,
own KIROCREW_HOME, no crons — never the live plane) via
website/scripts/capture-spec-builder.mjs, which asserts each state rendered
before shooting.

The git helper is routed through sandboxed_spawn_argv with a scrubbed env and
the resource-limit preexec (mirroring git_coord._git) rather than added to the
spawn-audit benign allowlist: its working directory is caller-supplied and the
branch name derives from a spec name, so it is agent-influenced.

Review round 2 — all five reported HIGHs:
1. Spec-dir files are untrusted even though the dir passed validation: reads and
   the STOP write go through _spec_file/_read_spec_text/_write_stop_sentinel,
   which refuse symlinks, require realpath containment, refuse sensitive
   targets, and write atomically with O_NOFOLLOW + os.replace (which destroys a
   planted link instead of writing through it).
2. Autonomous execution routes through authorize_and_add_nudge instead of
   svc.add, so it inherits the shared ownership checks, message limit,
   sensitive-sentinel refusal and SEL audit; fails closed by revoking trust and
   returning 403. max_cycles 0 (infinite) -> 60. The auto-approve grant now
   expires via _TRUST_TTL_SECS and is revoked on Stop through one _halt_execution
   chokepoint (Stop previously left trust set). A missing start timestamp fails
   closed.
3. .spec-state.json is projected onto the documented schema: unknown keys
   dropped, types enforced, lists capped, and KEYS redacted as well as values
   (a credential in an object key previously bypassed the value-only scrub).
   Malformed entries such as decisions:[null] are dropped, not forwarded.
4. Create returns 409 when the target already holds Kiro markdown, so an
   IDE/CLI-authored spec is never handed to an agent by accident; adopting is
   explicit via import_existing.
5. Containment regression fixed: a worktree is a SIBLING of the checkout, so
   after creating one it is re-validated and becomes the containment root.
   Previously every worktree-mode create failed containment and orphaned the
   worktree; failures now roll it back via _remove_worktree.

14 new tests (34 total in the builtin), each guard revert-verified.

Embedded chats can now action a pending tool approval. ChatMessageList only
renders Approve/Reject when an onApprove handler is supplied and ChatEmbed
supplied none, so a permission prompt raised mid-turn showed a dead "Approval
needed" label and the worker blocked until the runner's timeout auto-rejected
it. ChatEmbed now resolves decisions via POST /api/approvals/{id}/{action}
through the scoped app API, and the builtin grants '/api/approvals' alongside
'/api/chat' (the external app's manifest already did).

Embedded tool calls now read like a main session. ChatEmbed's inline
ToolCallPill rendered EVERY state as one accent-purple spinning Wrench
(animate-spin, 2s) with the raw command hard-truncated to 80 chars, so a
finished call was indistinguishable from an in-flight one and the transcript
looked permanently busy. It now uses state-aware icons (LoaderCircle running /
CircleDot done / CircleSlash rejected / Lock awaiting approval) with matching
status colours, prefers the backend-stamped meta.purpose over the raw command,
and offers the same file affordance as the main chat via the pure
extractToolFilePath/isSafePath helpers (no Redux — ToolCallLine itself depends
on store state the embed's prop-driven session never populates, which is why the
pill was brought up to parity rather than swapped out). The spinner is gated on
the session's running flag, so a tool call orphaned by a dropped turn stops
spinning instead of looking busy forever. 5 parity tests, revert-verified.

Review round 3:
- The auto-approval TTL now ages EVERY grant, not just an executing run. Trust
  is stamped on three paths (create / message / execute) but the TTL opened with
  a status=="executing" gate, so both planning grants were unexpiring: create a
  spec, stay in planning, and the worker auto-approved tools forever. Grants now
  record trust_granted + trust_at; expiry halts an executing run as before and
  revokes a planning grant in place. A source guard fails if a future grant site
  forgets to record its timestamp.
- The browse scan moved off the event loop. sorted(os.scandir()) plus a realpath
  and sensitive-path test PER ENTRY ran inline in the aiohttp handler, stalling
  chat streaming and heartbeats on large directories; it now runs via
  asyncio.to_thread and is bounded at 500 entries.
- is_sensitive_path is imported at module scope (four function-local copies
  removed) with a fail-closed fallback: callers use it to decide whether a path
  may be read, written or browsed, so an unavailable security module must deny.

Review round 4:
- Spec reads are descriptor-pinned. _spec_file validated the path and then
  read_text() opened it BY NAME; the agent writes into that same directory and
  the UI polls every 2.5s, so the check-to-open window was real and repeatable.
  Reads now go through safe_read_file_bytes_nolink(within_root=spec_dir), which
  opens O_NOFOLLOW first and validates the descriptor it read, and are capped at
  1 MiB.
- The app no longer grants worker trust at all. The TTL was enforced from the
  status-poll handler, so closing the browser tab stopped enforcement while the
  grant survived — not a bound. All three slot._trust grants and the whole TTL
  machinery are removed. The grant existed only because approval prompts were
  invisible in the embedded chat, and this PR fixed that, so the decision now
  belongs to the user through core's own trust mechanism where it is auditable
  as their choice. Stop and the authz-failure path no longer clear trust either:
  if the user granted it, this app must not silently undo it. Source guards keep
  both the grant and the TTL from coming back.
- The specs list uses useQuery instead of useState + setInterval; two
  overlapping manual polls could resolve out of order and overwrite fresh server
  state with stale data.

Review round 5 (rebased onto current main):
- The two POLLED endpoints no longer do filesystem work on the event loop. The
  detail endpoint (polled every 2.5s during a build) stat-ed three phase files,
  read up to three 1 MiB documents and read .spec-state.json inline; the list
  endpoint walked every known project root's .kiro/specs. Both froze the gateway
  loop, chat streaming and heartbeats included. Detail now makes ONE thread hop
  through _collect_spec_documents (bundled so a future edit can't reintroduce an
  inline read); discovery goes through asyncio.to_thread. Round 3 fixed this
  class only at the browse endpoint and missed both siblings.
- Deleting a spec now tears down its worker slot. Previously it removed the nudge
  loop and the index entry but left the in-flight turn ALIVE, so the agent kept
  editing the user's files after the spec was deleted, and re-creating the same
  name resurrected the old transcript (get_or_create_slot keys off the name).
  _teardown_worker_slot mirrors the gateway's own order: pop from the registry
  before any await, cancel, await under a bounded shield, then save as closed.
  It refuses slots this app does not own.
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.

4 participants