decision-system S6: SessionStart extraction hook #1248
Labels
No labels
P0
P1
P2
P3
bug
category/code-change
category/cross-repo
category/eval-run
category/investigation
category/no-test-target
category/owner-ruling
category/prose-artifact
category/recurring-chore
category/structure
category/umbrella
crc-refactor
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
rule-proposal
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#1248
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Extraction runs only when Jared types
os-me-extract. Moments from new sessions do not reach the Store until he does.MomentExtractor#runreads every unread session in one run and has no limit on model calls, so a hook cannot call it as it is. Each new memo costs oneclaudesubprocess (ReplyShape#classifythroughOsMe::LabelerClient.for(OsMe::HeadlessClaude)), and the first run sees about 650 unread sessions.Approach
A SessionStart hook starts a detached
os-me-extract --budget 20child and exits 0 at once. It prints nothing. The hook follows the two existing background patterns in this repository:OsSdlc::Db::MirrorTickle.spawn_sync(Process.detach(Process.spawn(...))) and the os-vault SessionStart rebuild (start_new_session=True). Nohooks.jsonin this repository uses anasyncfield, so the hook script itself does the detach.Budget unit: model calls per run. One new memo moment is one
ReplyShape#classifycall, which is oneclaudesubprocess, so the extractor counts the moments it writes. The extractor checks the budget between sessions. It starts no new session after it has madebudgetcalls. A session that has started always finishes, becauseMomentLog#appendwrites the moments of one session together andExtractCursor#advancemoves after that write. A run can thus go past the budget by the memos of its last session. The next run continues at the first unread session.Two sessions can start at almost the same time. A second run then exits 0 at once when another run holds the lock file
queue/extract.lockin the Store. This stops double model calls on the same unread sessions.Held-out routing does not change:
Split#held_out?sends held-out moments toeval/moments-held-out.jsonland never tomoments.jsonl.os-me-extractwith no--budgetkeeps its current behavior.Required work:
MomentExtractor#run(budget: nil)counts model calls and starts no new session afterbudgetcalls — check: presentbudgetinplugins/os-me/lib/os_me/moment_extractor.rbMomentExtractor#runholds a non-blockingflockonqueue/extract.lockin the Store and returns without work when another run holds it — check: presentextract.lockinplugins/os-me/lib/os_me/moment_extractor.rbextracttakes--budget N(numeric, optional) and passes it torun— check: presentoption :budgetinplugins/os-me/lib/os_me/cli.rbOsMe::ExtractLauncher(hook object, same place asOsMe::InvocationLog) spawns the detached extract child only when the Store directory exists — check: presentclass ExtractLauncherinplugins/os-me/hooks/extract_launcher.rbextract_moments.rbbuilds the launcher with the Store~/dev/os-me-store, rescuesStandardError, and exits 0, likelog_invocation.rb— check: presentexit 0inplugins/os-me/hooks/extract_moments.rbhooks.jsongains aSessionStartentry with matcherstartup, commandruby ${CLAUDE_PLUGIN_ROOT}/hooks/extract_moments.rb, and timeout 5 — check: presenthooks/extract_moments.rbinplugins/os-me/hooks/hooks.jsonclaudeon PATH, asextract_cli_test.rbdoes — check: present--budgetinplugins/os-me/test/extract_budget_test.rbExtractLauncherinplugins/os-me/test/extract_launcher_test.rbOrchestrator work: no ADR. O-10 was challenged and rejected on 2026-10-23 (
docs/decision-system/afk-adr-log.md). Runbin/refresh-pluginsafter the hook lands. Gate: start two real Claude Code sessions withstartup;moments.jsonloreval/moments-held-out.jsonlin the Store gains rows, and session start time grows by no more than 1 s. If the start grows by more than 1 s, makeBUDGETsmaller; do not change the hook contract. Tests never call the realclaude.Acceptance criteria
queue/extract-cursor.jsonholds the current mtime of every transcript under--transcript-root, whenos-me-extract --budget 20runs, then the fakeclaudegets 0 calls,moments.jsonldoes not change, stdout is empty, and the exit code is 0.os-me-extract --budget 2runs, then the fakeclaudegets 2 calls,moments.jsonlholds 2 rows, andqueue/extract-cursor.jsonholds 2 session ids.os-me-extract --budget 2runs again, then the fakeclaudegets 1 more call (3 in total),moments.jsonlholds 3 rows, and the cursor holds 3 session ids.os-me-extract --budget 1runs, then the fakeclaudegets 2 calls,moments.jsonlholds 2 rows from one session, and the cursor holds 1 session id.os-me-extract --budget 20runs, theneval/moments-held-out.jsonlholds 1 row with the held-out session id andmoments.jsonlholds 1 row with"split":"train"and no held-out session id.flock(LOCK_EX)on<store>/queue/extract.lock, whenos-me-extract --budget 20runs, then the fakeclaudegets 0 calls, stdout is empty, the exit code is 0, and the cursor does not change.OsMe::ExtractLauncher.new(store: path, spawner: recorder).launchruns, then the recorder gets 0 calls; and whenruby plugins/os-me/hooks/extract_moments.rbruns withHOMEset to an empty directory and stdin{"hook_event_name":"SessionStart","source":"startup"}, then stdout and stderr are empty and the exit code is 0.HOMEwithdev/os-me-storeand one unread train session with a memo under.claude/projects, and a fakeclaudethat sleeps 2 s before it answers, whenruby plugins/os-me/hooks/extract_moments.rbruns with the stdin of criterion 7, then it exits 0 in less than 1 s with empty stdout, anddev/os-me-store/moments.jsonlholds 1 row within 15 s.plugins/os-me/hooks/hooks.json, when it is parsed, thenhooks.SessionStartholds one entry with"matcher":"startup"whose one hook has"type":"command","command":"ruby ${CLAUDE_PLUGIN_ROOT}/hooks/extract_moments.rb", and"timeout":5.Owner sketch
extract_and_advancereturns the number of moments that it wrote (0 when the transcript vanished), andextractreturnsbatch.size.Non-goals
asyncfield inhooks.json.resume,clear, orcompact.Split,ExtractCursor, or the record format ofDecisionMoment.Origin
DESIGN(docs/decision-system/build-plan.mdS6).VERDICT: CREATE (user-approved; backlog search only)
Work started on branch ticket-1248 (os-sdlc implement).
Run 135 failed: diff-test does not map plugins/os-me/hooks/test (the os-me target has one test_path), so the launcher test had no red run and the test-reviewer returned error. Moved the launcher test path to plugins/os-me/test/extract_launcher_test.rb and reopened on ticket-1248.
Resolution
Done: Budgeted, locked os-me-extract run (--budget N, flock on queue/extract.lock); OsMe::ExtractLauncher and SessionStart hook extract_moments.rb (matcher startup, detached child, budget 20, prints nothing, exits 0)
Evidence: implementation 136 merge-ready, 8 dispatches, 0 retries; suite and lint gates pass; criterion 8 test shows the hook exits under 1 s with a 2 s fake claude; commit on ticket-1248 (9 files, +299/-7)
Follow-ups: S6 gate (two real SessionStarts add moments, start time grows under 1 s) is measured live and recorded in build-plan.md; os-sdlc single test_path defect filed after merge