Skip to content

feat: merge study command + E2E experience tests + bridge CLI - #2

Merged
NetDevAutomate merged 39 commits into
mainfrom
feat/study-command-e2e
Apr 1, 2026
Merged

NetDevAutomate merged 39 commits into
mainfrom
feat/study-command-e2e

Conversation

@NetDevAutomate

Copy link
Copy Markdown
Owner

Summary

  • Merge feat+live-session-dashboard worktree — 37 commits adding studyctl study (tmux orchestrator + Textual sidebar + session state + agent launcher + parking lot)
  • Re-register bridge CLI command — dropped during compaction but DB functions preserved; CLI entry point restored
  • 4 new E2E experience verification tests — verify what users actually see, not just plumbing:
    1. Sidebar pane renders topics (not just IPC file check)
    2. Cleanup notes flow through DB into resume persona (full chain)
    3. Strict directory reuse assertion on resume (no OR fallback)
    4. Sidebar displays elapsed time
  • Deliberate CLI compaction — removes 9 unused commands (schedule, progress-map, teachback, etc.) per the compact-to-core plan

Test plan

  • Full test suite: 708 passed, 6 skipped (up from 650)
  • E2E integration: 31 tests pass (27 original + 4 new experience tests)
  • studyctl study --help works
  • studyctl bridge --help shows add/list
  • studyctl session --help shows start/end/status
  • Removed commands absent from studyctl --help
  • All pre-commit hooks pass (ruff, pyright, secrets, pytest)

Post-Deploy Monitoring & Validation

No additional operational monitoring required: CLI tool distributed via PyPI — users upgrade at their own pace.

🤖 Generated with Claude Code

taylaand and others added 30 commits March 23, 2026 09:53
…or tests

- Delete TUI dashboard, scheduler, calendar, state CLI
- Prune CLI: remove wins/streaks/resume/progress-map/bridges/teachback/schedule
- Archive Crush, Amp, and 5 extra Claude agent definitions
- Fix doctor integration tests: block network in autouse fixture
- Restore state.py (needed for NotebookLM SyncState, not push/pull CLI)
- Rewrite README: 426 → 162 lines, focused on 4 core use cases
- All tests green: 612 passed (247 studyctl + 357 session-tools + 8 doctor)
- cli-reference.md: rewritten to reflect 13 commands (removed TUI, schedule, state, wins, streaks, bridges, teachback)
- setup-guide.md: removed TUI section, replaced state push/pull with session-sync
- roadmap.md: added compaction notice at top
- TODO.md: rewritten with archived features section
- session-protocol.md: replaced studyctl wins/schedule-blocks references
- audhd-framework.md: replaced studyctl wins reference
- audhd-learning-philosophy.md: replaced studyctl bridge add reference
…CLI, IPC protocol

Phase 1 of the live study session dashboard. Closes the critical gap
where parking lot (AuDHD rabbit-hole prevention) had zero DB backing
despite being documented in 4 agent framework files.

New modules:
- parking.py: 6 CRUD functions for parked_topics table (park, get,
  schedule, resolve, dismiss)
- session_state.py: IPC file read/write/parse for dashboard viewports
  (session-state.json, session-topics.md, session-parking.md)
- cli/_session.py: session start/end/status and park commands

Changes:
- Migration v14: parked_topics table with status lifecycle
  (pending → scheduled → resolved/dismissed)
- Re-added resume, streaks, wins, progress commands from main
- Agent protocols updated with Session File Protocol instructions
  (Claude, Gemini, OpenCode, shared session-protocol.md)

Tests: 26 new tests (9 parking, 12 session_state, 5 CLI session)
Full suite: 638 passed, 5 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cmux MCP server (cmuxlayer) added to Claude, Gemini, and OpenCode agent
configs. New "cmux Dashboard Protocol" section in session-protocol.md
with complete MCP tool call sequences for:

- Session start: split pane, sidebar status/progress, tab rename
- During session: topic updates, counter increments, timer progression,
  break notifications
- Session end: summary view, progress complete marker

The cmux viewport is additive — file-IPC writes continue unconditionally.
If cmux MCP tools are unavailable (non-macOS, not in cmux terminal),
the agent skips cmux calls silently and falls back to file-only IPC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Design decisions and rationale for the live study session dashboard:
agent-initiated sessions (no start question), combined activity pane
with color+shape accessibility, energy-adaptive timer from break-science.md,
file-IPC protocol, 3-viewport architecture (cmux, web PWA, TUI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SSE-powered live session dashboard at /session with HTMX + Alpine.js.
The agent writes to IPC files during a study session; the web app polls
them via SSE and pushes HTML fragments with OOB swaps for real-time
updates without page reloads.

New:
- web/routes/session.py: SSE stream (/api/session/stream) + JSON state
  endpoint (/api/session/state), 5 HTML rendering helpers with XSS-safe
  escaping, shapes+colors for accessibility (✓ ★ ◆ ▲ ○)
- web/static/session.html: Dashboard page with HTMX SSE extension,
  Alpine.js energy-adaptive timer (thresholds from break-science.md),
  activity feed, counter bar (WINS|PARKED|REVIEW), summary view
- Session indicator banner on courses page when a session is active
- Service worker v2: skip caching SSE streams and HTMX fragments

Tests: 14 new tests (page serving, API state, SSE format, render
functions for activity feed, counters, summary, XSS escaping)
Full suite: 677 passed, 3 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes from live testing:

1. parking.py: _connect() now checks if parked_topics table exists and
   runs agent-session-tools migrations automatically if missing. Previously
   park_topic() would fail with "no such table" on databases that hadn't
   been migrated to v14.

2. session.html: Added pause/resume and reset buttons to the session
   timer. Pause shifts startTime forward on resume so elapsed time only
   counts active study time. Reset starts fresh with threshold messages
   cleared. "Paused" label shown in amber when timer is paused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Architecture decisions for the next evolution of studyctl:
- tmux as session runtime, all viewports read from it
- `studyctl study` as universal entry point (CLI, /studyctl, @studyctl)
- One layout all modes (study, co-study), mode changes agent behaviour
- Two-port model: dashboard (read-only, safe) + ttyd terminal (auth'd)
- Always-on server: start on Mac Mini, continue from any device
- Timer: elapsed + pomodoro, both pausable, energy-adaptive thresholds
- Speech-to-text recommendations (Warp, Handy, Wispr Flow)
- UX: session resume, quick capture, "I'm stuck" buttons, energy tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents 5 false assumptions caught by 9 research agents before any
code was written:

1. ttyd TTYD_CREDENTIAL env var does not exist — use nginx proxy
2. age CLI requires TTY — use pyrage + macOS Keychain instead
3. Only Claude supports --system-prompt-file — others need config files
4. tmux -f is server-level, ignored on running server — use source-file
5. Distributed timer needs reconciliation — stateless computation simpler

Includes prevention protocol: treat every CLI capability in a plan as
an untested hypothesis, verify against actual binaries before coding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- SSE mtime optimization: replace JSON hash with 3 stat() calls for O(1)
  change detection in session stream endpoint
- Parking dedup: migration v15 adds unique index on (study_session_id,
  question), INSERT OR IGNORE prevents session_end re-inserting entries
- IPC file permissions: 0700 on session dir, 0600 on all IPC files via
  os.open() with explicit mode bits
- CORS wildcard removed from stdlib web server
- Docs: README architecture diagram, CLI reference, and roadmap updated
  for live session dashboard features (session CLI, SSE, parking lot)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New `studyctl study` command creates a complete tmux-based study
environment with one command: agent pane + Textual sidebar + optional
web dashboard.

New modules:
- tmux.py: thin wrapper with -P -F pane IDs, source-file config,
  os.execvp attach, file lock for creation, version detection
- agent_launcher.py: detect installed agents, build persona files
  with mkstemp 0600, Claude-only initially (dict makes adding trivial)
- tui/sidebar.py: Textual app with TimerWidget (energy-adaptive
  colour phases from break-science.md), ActivityFeed, CounterBar,
  IPC polling via worker thread, session-oneline.txt for tmux status bar
- cli/_study.py: orchestrator — session start, tmux layout, agent
  launch, sidebar launch, --resume, --end, --web, --mode co-study

Also:
- Agent persona files (study.md, co-study.md) in agents/shared/personas/
- 39 new tests (test_tmux, test_agent_launcher, test_study)
- 359 studyctl tests pass, 3 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mmand

- README: replace session start/end with studyctl study as primary entry
- CLI reference: add full studyctl study section with modes, flags, and
  architecture description; demote session commands to "low-level"
- Roadmap: mark Phase 1 Unified Session as complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes from live testing:

- Auto-switch when already in tmux: use switch-client instead of
  printing a hint. Covers both study start and --resume paths.
- Sidebar launch: use sys.executable + python -m studyctl.tui.sidebar
  instead of uv run (which needs a project dir). Added __main__.py
  entry points for both studyctl.tui and studyctl.tui.sidebar.
- Split pane: use percentage (25%) not fixed columns (35) so the
  sidebar adapts to terminal width.

Also:
- Bundled catppuccin-inspired tmux config at data/tmux-studyctl.conf
  (loaded via source-file, user override at ~/.config/studyctl/)
- Added switch_client() to tmux.py wrapper
- Quick park popup: prefix+p opens display-popup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…imensions

Root cause: create_session used -x 200 -y 50, so the 25% split was
calculated at 200 columns. When the client switched/attached, tmux
resized but the split ratio was distorted (sidebar became a sliver).

Fix:
- Remove -x/-y from create_session — let session use default size
- Switch client BEFORE splitting when already in tmux, so the split
  percentage is calculated against the actual terminal width
- Update test to match new code path (no console output in tmux path)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`from textual.worker import work` fails on Textual 8.x. The correct
import is `from textual import work` (re-exported at package level).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two improvements from live testing:

- Pass command= to create_session and split_pane instead of send_keys.
  Commands run directly in the pane — no shell prompt, no visible
  command text in scrollback. Main pane shows only Claude Code output.
- Trim tmux-studyctl.conf to minimal overlay: only status-right
  (oneline readout) and prefix+p (quick park popup). Does not override
  user's prefix, keybindings, theme, colours, or mouse settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The bundled tmux-studyctl.conf overrides status-right (clobbering
catppuccin's generated output) and binds prefix+p (conflicting with
floax). Only load a user's explicit override at
~/.config/studyctl/tmux-studyctl.conf. The bundled file remains as
a reference template in data/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-technical users shouldn't need to know about tmux or IPC files.
When the agent process exits (user quits Claude, /exit, or Ctrl+C),
a wrapper command automatically:
- Ends the DB session
- Cleans up persona + IPC files
- Switches tmux client back to the previous session
- Kills the study tmux session

Also:
- Sidebar Q (uppercase) triggers full session end from the sidebar
- studyctl study --end now switches back to previous tmux session
- _cleanup_session() is the shared cleanup function for all exit paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each study session now runs in its own directory at
~/.config/studyctl/sessions/{session-name}/. The AI agent's
conversation history (.claude/, .kiro/, etc.) is preserved there.

This enables true conversation resume: studyctl study --resume
detects the existing session directory, passes -r to the agent
(e.g. claude -r), and the AI picks up where it left off — not
just tmux reattachment but actual conversation continuity.

Changes:
- tmux.py: create_session accepts cwd parameter
- agent_launcher.py: get_launch_command accepts resume=True,
  uses agent's resume command template (claude -r)
- _study.py: creates session dir, detects existing .claude/ for
  auto-resume, stores session_dir + agent in state
- _handle_resume: two scenarios — live tmux (reattach) or dead
  tmux with preserved dir (rebuild + resume conversation)
- Session dirs persist after --end (conversation history kept)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
agent_launcher.py traverses from src/studyctl/ to the repo root
to find agents/shared/personas/. Was off by one parent level,
landing in packages/ instead of the repo root. Persona files
(study.md, co-study.md) were silently falling back to inline
defaults instead of using the richer persona templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Same class of issue as the sidebar launch — uv run depends on
being in a project directory. Use shutil.which("studyctl") first
(installed entry point), fall back to python -m.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the full session lifecycle: persistent session directories,
conversation history resume with -r, auto-cleanup on agent exit,
sidebar Q to end session. Update roadmap Phase 1 description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comprehensive document showing how all components connect:
- Content pipeline (Obsidian → pandoc → NotebookLM → artefacts)
- Study sessions (tmux + agent + sidebar + web dashboard)
- Review system (SM-2 spaced repetition via Web PWA)
- Session intelligence (export, search, cross-machine sync)
- Agent protocol (shared Socratic engine, personas, AuDHD framework)
- End-to-end workflow example from materials to mastery
- Data stores, prerequisites, and install options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TimerWidget now has two modes:
- elapsed: counts up with energy-adaptive colour phases (unchanged)
- pomodoro: 25/5/25/5/25/5/25/15 cycle with countdown display

Sidebar shows: "12:34 FOCUS 2/4" during work blocks, "04:12 BREAK"
during short breaks, "08:30 LONG BREAK" during long breaks. Colours:
green=focus, cyan=short break, magenta=long break.

The tmux status bar oneline also reflects pomodoro state:
"Topic | 12:34 F2 | E:7 | W:3 P:1 R:2"

Start a pomodoro session with:
  studyctl study "topic" --timer pomodoro
  studyctl study "topic" --mode co-study  (defaults to pomodoro)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bring the single source of truth current with all work from the
feat+live-session-dashboard branch: studyctl study, tmux, sidebar,
pomodoro, auto-cleanup, session directories, resume. Reorganise
next steps into v2.2 Polish, Phase 3 Devices, Phase 6 CI/CD.
Add key file references for new modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues preventing --resume from working:

1. is_session_active() only checked study_session_id, not mode.
   After cleanup writes mode="ended", the session appeared active.
   Fix: return False when mode == "ended".

2. _handle_resume didn't clear stale IPC files before calling
   _handle_start, so _handle_start saw "already active".
   Fix: call clear_session_files() before rebuilding.

Also fixed mode="ended" leaking into the rebuilt session (was
passing "ended" as the session mode instead of "study").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes for --resume:

1. Resume now passes the existing session_name and session_dir to
   _handle_start, so the rebuilt tmux session lands in the same
   directory with the preserved .claude/ conversation history.
   Previously it created a fresh session dir (new study_id).

2. Both _cleanup_session and _handle_end now capture session context
   (topics covered, wins, struggles, parked questions) as notes in
   the study_sessions DB record before ending. This gives the agent
   context about where the conversation was when it resumes.

Also fixed is_session_active() to return False when mode=="ended",
preventing the "already active" block on resume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Code stores conversation history in ~/.claude/projects/{path}/,
not .claude/ in the working directory. The resume detection was checking
(session_dir / ".claude").exists() which was always False.

Now checks ~/.claude/projects/{mangled-session-path}/ which is where
Claude actually writes its history. Also moved Path import to cover
both branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When ending via sidebar Q, send C-c to the agent pane first so
Claude Code exits gracefully and writes its conversation history.
Wait 2 seconds for the write, then run cleanup. Without this,
killing the tmux session terminates Claude abruptly and the
conversation history may not be saved for --resume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes for sidebar activity and resume quality:

1. New `studyctl topic` CLI command — agents call this to log topics
   to the sidebar activity feed. Replaces unreliable raw file writes.
   Example: studyctl topic "Closures" --status win --note "understood"

2. Personas rewritten to use CLI commands (studyctl topic, studyctl park)
   instead of raw file writes. Clear instructions on WHEN to log
   (after every significant exchange).

3. Resume injects previous session notes into the persona file.
   The agent gets context like "Wins: Decorators. Struggling: Metaclasses.
   Parked: How does asyncio compare?" and is told to pick up where
   the student left off.

Also: sidebar Q simplified — sends C-c to agent pane and exits.
The wrapper command handles cleanup (no competing cleanup calls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit 7147ca6) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.

- index.html: inline key-entry row, shown only when the selected provider is
  keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
  save' -> POST /api/content/secrets; on success clears the raw key from
  memory, re-fetches /providers so the option flips to enabled, shows a
  'verified & stored encrypted' confirmation; on 400/422 shows the provider's
  rejection message. needsKey / selectedProvider getters; keyEntry state reset
  on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
  palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
  unavailable keyed provider, hidden for available one, save POSTs correct
  {provider,key} body + shows success, rejection shows error.

Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit fd294f4) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.

- index.html: inline key-entry row, shown only when the selected provider is
  keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
  save' -> POST /api/content/secrets; on success clears the raw key from
  memory, re-fetches /providers so the option flips to enabled, shows a
  'verified & stored encrypted' confirmation; on 400/422 shows the provider's
  rejection message. needsKey / selectedProvider getters; keyEntry state reset
  on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
  palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
  unavailable keyed provider, hidden for available one, save POSTs correct
  {provider,key} body + shows success, rejection shows error.

Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit 4e7207a) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.

- index.html: inline key-entry row, shown only when the selected provider is
  keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
  save' -> POST /api/content/secrets; on success clears the raw key from
  memory, re-fetches /providers so the option flips to enabled, shows a
  'verified & stored encrypted' confirmation; on 400/422 shows the provider's
  rejection message. needsKey / selectedProvider getters; keyEntry state reset
  on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
  palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
  unavailable keyed provider, hidden for available one, save POSTs correct
  {provider,key} body + shows success, rejection shows error.

Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
feat: merge study command + E2E experience tests + bridge CLI
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
The encrypted secrets store (secrets.py, commit f788055) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.

- index.html: inline key-entry row, shown only when the selected provider is
  keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
  save' -> POST /api/content/secrets; on success clears the raw key from
  memory, re-fetches /providers so the option flips to enabled, shows a
  'verified & stored encrypted' confirmation; on 400/422 shows the provider's
  rejection message. needsKey / selectedProvider getters; keyEntry state reset
  on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
  palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
  unavailable keyed provider, hidden for available one, save POSTs correct
  {provider,key} body + shows success, rejection shows error.

Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Oct 12, 2026
… (W2)

Why: proposal §7's Labs row and council M0 Q2 (option b): the drill runs in
`studyloop drill`, in another process; its result reaches the mentor through
ONE loopback route in the web server, which re-renders and bounds (lane T),
never trusting CLI-rendered text.

`web/routes/modules.py`: GET /api/modules/labs lists installed modules'
decks and tasks by id and prompt (+ successCriteria, phases and the exact
drill commands) -- never hint/solution/mentorNotes (§5.6, C7; a tripwire
asserts it). POST /api/modules/labs/start reuses the one session-start path
(201 start payload + drill) or binds the drill to the live session (200,
attached); it records `labs_drill` and the capability (token + web_port)
in the state file. Nothing executes here: the browser has no consent
surface in v1 (§5.5, `drill` has no --yes; §8 browser execution is v1.1).
POST /api/modules/labs/cancel clears the drill and touches nothing else.
POST /api/modules/practice-check: loopback peer only (403), token header
(401) compared in constant time to the state file (403), the token's
session must be the active one (403), lane T's validator (422 naming the
field, never the value), replay dedup on (attempt_id, phase) (409), burst
5/min per session (429), then deliver_to_active_session -> 200 with the
outcome's status/reason -- a non-landing delivery is a 200 that says so,
because the hop maps any non-2xx to its own `error`.

`_options.py`: `labs` on GET /session/options only when non-empty, read
fresh (the lockfile is not in the index fingerprint). `app.py`: the one
include line (moved here from W3: W1 cannot go green without it).
`orchestrator.py`: `mint_practice_check_token()` and its one call beside
the web_pid/web_port write (W2 brief, owned block #2). `labs.js`: the
store (one entry, hidden with no module), the view (start -> Study Session
view, reload re-adopts through the tested re-attach path; attached start
needs none; cancel also drops an in-flight start), and the client-side
scrub of mentor-only fields. index.html/style.css: the sidebar entry and
the view; `.labs-error`, not `.picker-error`, keeps the picker's strict
single-selector contract.

`lane_ownership.yaml`: the two W2 grants (orchestrator.py, _drill.py) and
the S3 lease (mkdocs.yml) recorded as dual ownership so the guard enforces
the brief instead of refusing it.

Evidence: evidence/W/w1-web-modules.green.txt (21 passed),
w1-module-labs-js.green.txt (17 pass), w2-gate-g.txt (lint, pyright 0,
test-js 184, docs strict, release-consistency, spec-check green; just test
7885 passed / 3 failed: the pre-existing tmux red and the two
coverage-ratchet tests that W4's browser journey satisfies).
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…ngs.json

_configure_claude copied the shipped settings template only when ~/.claude/settings.json did
not exist, so every Claude Code user who already had any setting never received the status line
at all, and only the stale entry an earlier uninstall left behind made a reinstall look as if it
had worked. Lane B (plan 2, B4) made uninstall remove that owned entry, which turned the silent
gap into a visible one: install -> uninstall -> install ended with no statusLine (its FNF-1).

_add_claude_statusline is the mirror image of lane B's _remove_claude_statusline: a learner's
own statusLine is kept untouched; otherwise the member is inserted as bytes before the closing
brace, indented like the file's last member, every other byte stays, and the edit is parsed
back and must equal the original plus the entry before it is written. A file that cannot be
read as a JSON object is never edited.

RED: 3 new contract tests fail without the src change (existing-file add, install/uninstall/
install round trip, byte-preserving add on _configure_claude directly); 72 pass with it. The
no-reflow property is asserted on _configure_claude alone because install_claude_stop_hook,
which runs after it, still reserialises the whole file (FOUND-NOT-FIXED lane B #2, separate).
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
install_claude_stop_hook, _merge_session_end_hook (Codex, Grok) and
_remove_owned_hooks rewrote the learner's hook registry with
json.dumps(indent=2): a hand-formatted ~/.claude/settings.json was
reflowed by install and every non-ASCII character in it escaped, and
uninstall could not give those bytes back. Lane 6's MCP merge/removers
and lane B's statusLine adder/remover were already byte-preserving, so
the end-to-end install still reflowed through this one writer (lane B
found-not-fixed #2).

The three now edit bytes the way the statusLine functions do, on a
small set of span helpers added beside the existing JSON walkers:
_json_array_element_spans, _json_children, _json_path_span (member
names and array indexes from the root), _json_cut_child (cut one child
with the separator that joined it), and _append_json_children, which
_append_json_members now delegates to and _append_json_elements
mirrors for arrays. Every edit is parsed back and must equal the
original plus/minus the owned group before it is written; otherwise
nothing is written and InstallError names the file (duplicate-key
files are the case that trips it). Files are read and written as
bytes, so a CRLF file stays CRLF.

_append_json_members also stops moving a column-0 closing brace: it
re-indented the brace to two spaces and put the new member at four
whenever the container closed at column 0 (every json.dumps(indent=2)
root object), which made a root-level append-then-cut round trip
impossible. New children now follow the container's own layout:
one-line containers get ", "; multi-line ones a new line indented like
the last child, nested lines stepped by the file's own unit (spaces or
tabs); an empty container is opened like json.dumps would, relative to
the line it starts on. The whitespace before the closing bracket is
kept. The nested mcpServers shape the MCP merge relies on renders
identically to before (test_mcp_registration: 48 passed).

Semantics of the remover are unchanged, including the pre-existing
ambiguity that a "Stop": [] or "hooks": {} the learner already had is
dropped when our group was the only one in it (a file cannot say who
created an empty list); that case is recorded in the lane log.

Tests plant hand-formatted files (4-space indent, one-line groups, a
non-ASCII «fertig») for each branch of the writer -- no hooks, hooks
with no Stop, Stop with a learner's group, a legacy command rewritten
in place -- and the Codex registry; each checks the planted lines
survive and that uninstall returns the exact bytes. End to end through
install_agent_definitions as well. RED on the unfixed tree, nine
failures of the form:
  reflowed or escaped: '    "theme": "café",'
  assert '    "theme": "café",' in ['{', '  "theme": "caf\\u00e9",', ...]
  Failed: DID NOT RAISE InstallError   (duplicate-key refusal)

Lane J (harness-small), B#2.
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
write_mcp_config only exercised whichever branch the developer's PATH took,
and the dev=True cases monkeypatch the function away, so the PATH-first /
uv-run fallback had no direct pin after lane O deleted the launcher's dead
copy (review 4, found-not-fixed #2). Two tests, one per branch, assert the
exact argv: the bare console-script name when studyloop-mcp is on PATH (the
dead copy returned the absolute path), and uv run --project <workspace
package> studyloop-mcp when it is not.
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…he record 0700 [plan 5, larger #2 item 5]

Every copy of the 0600 record (backups, snapshots, the full tier, sidecars)
has been created owner-only since F-AST-14 / R-19c, but the directories that
hold them were created by a bare Path.mkdir under the process umask: 0755
under 022, so their listing (database stem + backup timestamps) was
world-readable and a copy any other tool dropped there started wide.
345c256 tried mode=0o700 in create_backup and f10f3c8 reverted it because
config_loader.get_backup_dir had already created the directory first; the
mode belongs at the three places that create these directories.

get_backup_dir, ensure_config_dir's default backup dir and
tiering.ensure_leaf_dir (snapshot dir, full-tier dir, compact destination)
create a missing directory 0700. A directory that already exists keeps its
mode -- a learner may have put it on a shared volume on purpose -- so only
`session-maint fix-perms` tightens pre-existing ones: database.backup_dir
always, database.snapshot_dir when a full tier is configured, only when this
user owns the directory (another owner is named and skipped), never through
a symlink, reported per directory, dry-run honoured, a failed chmod counted
toward the non-zero exit like a failed file.
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…estamp [plan 5, larger #2 item 6]

prune_hot selected candidates with `updated_at < ? OR updated_at IS NULL`, so
a session whose updated_at is NULL -- every Kiro v1 conversation and any
harness that gave none -- counted as "older than N days" however new it was:
a conversation from two days ago was evicted from hot on the next prune
(RED: 5 of 5 seeded sessions were candidates, two of them recent). delete and
archive moved to COALESCE(updated_at, created_at, MAX(messages.timestamp))
in 601863e (F-AST-06); prune now applies the same rule, schema-qualified for
the attached full DB, keeps a session of unknown age and counts it in the new
PruneStats.skipped_undated, which the prune command prints with the wording
delete and archive already use.
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…uld create, and names them [plan 5, larger #2 item 7]

After its deletes purge_session ran a whole-database PRAGMA foreign_key_check
and refused on ANY violation, so one pre-existing orphan anywhere -- a
session_notes row an archive/delete run before c0ff6e3 left behind, a
context_relations edge whose assertion is gone -- made every session-maint
delete, session-context forget --apply and modern prune refuse with "Purge
violated a database dependency" (lane 4 verified rc 1, nothing deleted).

The purge cannot create such an orphan: it requires PRAGMA foreign_keys = 1
and no constraint in the schema is deferred, so SQLite refuses a delete that
would leave a dependent row (verified: IntegrityError before the check ever
ran). The check is now scoped to what this purge changes: the tables holding
a foreign key into a table it deletes from, read from the schema at call
time, compared against a snapshot taken before the deletes (the shape
repair.py already uses). A violation present before is reported by table
and count and left exactly as found -- never repaired here -- while anything
new is a refusal naming its table. SQLite's own refusal of a dependent the
schema does not cascade is translated into the same RuntimeError, naming the
dependents of sessions that still hold a row, instead of a bare "FOREIGN KEY
constraint failed"; other IntegrityErrors (a trigger's RAISE) pass through
unchanged. Cost on a 50k-message database: 36 -> 51 ms per purge, the
before-snapshot's scan of messages.
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…yError, named [plan 5, larger #2 item 7, follow-up]

87ea3fa re-raised SQLite's "FOREIGN KEY constraint failed" as a RuntimeError
naming the dependent table. The full package suite showed that type is a
contract: test_replica_ledger::test_foreign_key_refusal_rolls_back_withdrawal_and_denials
pins that the replication withdrawal path (permissions.apply_change) sees an
sqlite3.IntegrityError matching "FOREIGN KEY" and rolls back on it. The
refusal is now PurgeDependencyError, a subclass of IntegrityError whose
message keeps SQLite's text and adds the dependent table(s) that still
reference the session -- the type contract holds, the table is named, and a
trigger's own RAISE mid-purge passes through untouched (new pin).
NetDevAutomate added a commit that referenced this pull request Oct 24, 2026
…wo natural verbs, four reclassified (pass 10 Q5)

Council pass 10 Q5 (lane A's RC3). Seats split three ways (leave / convert all nine / leave);
the ruling takes the text seriously and the label honestly:

- #3 "A struggle **repair** carries an energy demand of its own, which SHALL be derived once …"
  -> "A struggle **repair** SHALL carry an energy demand of its own, derived once …": the modal
  sits on the sentence's own finite verb instead of an inserted relative clause.
- #6 "Rule 3 SHALL be: It names the plan's next milestone …" -> "It SHALL name the plan's next
  milestone …": the scaffold stated nothing; the obligation is the sentence's.
- #1, #2, #8, #9 keep their text but move from `inherit-shall` to `modalise`: they are closing or
  tail sentences, not list items under "It SHALL apply these rules", so the kind now says what
  happened (one modal, one verb form; the plural "need" keeps its form).
- #4, #5, #7 stay `inherit-shall`: genuine list items under that lead-in.

Checker: 0 fail, 29 warn, 452 drift (inherit-shall 3, modalise 30). Strict valid. No test or
doc cites either changed sentence (the headings they sit under are unchanged).
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.

3 participants