From 79371125ebefb84a006633972feca5a4ae40265b Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Wed, 3 Jun 2026 16:06:24 +0200 Subject: [PATCH 01/18] feat(claude): Add raw hook capture and Claude agent configuration Implement hidden `sce hooks claude-capture` subcommand for raw Claude hook JSON intake with PostToolUse model enrichment from transcript. Add Pkl-generated Claude project settings with capture hook config, and Claude agent/skill/command definitions for the SCE workflow. - Add ClaudeCapture subcommand to hooks CLI surface (hidden) - Add ClaudeCaptureEvent enum: SessionStart, UserPromptSubmit, PostToolUse, Stop - Implement PostToolUse model enrichment via claude_transcript JSONL transcript model extraction helper - Refactor trace persistence into reusable building blocks (persist_serialized_trace_payload_at, persist_trace_payload_with_retries) - Add RepoPaths::claude_capture_tmp_dir() for canonical path ownership of context/tmp/claude/ - Add Pkl-generated config/.claude/settings.json registering capture hooks for all four event types - Add Claude agent, command, and skill definitions under .claude/ enabling SCE workflow in Claude Code - Update context documentation for new Claude capture surface Co-authored-by: SCE --- .claude/agents/shared-context-code.md | 60 +++++ .claude/agents/shared-context-plan.md | 66 +++++ .claude/commands/change-to-plan.md | 17 ++ .claude/commands/commit.md | 24 ++ .claude/commands/handover.md | 15 ++ .claude/commands/next-task.md | 24 ++ .claude/commands/validate.md | 15 ++ .claude/settings.json | 65 +++++ .claude/skills/sce-atomic-commit/SKILL.md | 90 +++++++ .claude/skills/sce-bootstrap-context/SKILL.md | 57 +++++ .claude/skills/sce-context-sync/SKILL.md | 93 +++++++ .claude/skills/sce-handover-writer/SKILL.md | 48 ++++ .claude/skills/sce-plan-authoring/SKILL.md | 89 +++++++ .claude/skills/sce-plan-review/SKILL.md | 91 +++++++ .claude/skills/sce-task-execution/SKILL.md | 58 +++++ .claude/skills/sce-validation/SKILL.md | 47 ++++ cli/Cargo.lock | 1 + cli/Cargo.toml | 3 + cli/src/cli_schema.rs | 10 + cli/src/services/default_paths.rs | 5 + cli/src/services/hooks/claude_transcript.rs | 85 +++++++ cli/src/services/hooks/mod.rs | 238 +++++++++++++++++- cli/src/services/parse/command_runtime.rs | 44 ++-- config/.claude/settings.json | 65 +++++ config/pkl/generate.pkl | 3 + config/pkl/renderers/claude-content.pkl | 71 ++++++ context/architecture.md | 9 +- context/cli/cli-command-surface.md | 4 +- context/cli/default-path-catalog.md | 3 +- context/context-map.md | 3 +- context/glossary.md | 7 +- context/overview.md | 5 +- context/patterns.md | 2 +- context/sce/agent-trace-db.md | 4 +- .../sce/agent-trace-hooks-command-routing.md | 6 +- context/sce/claude-raw-hook-capture.md | 63 +++++ 36 files changed, 1447 insertions(+), 43 deletions(-) create mode 100644 .claude/agents/shared-context-code.md create mode 100644 .claude/agents/shared-context-plan.md create mode 100644 .claude/commands/change-to-plan.md create mode 100644 .claude/commands/commit.md create mode 100644 .claude/commands/handover.md create mode 100644 .claude/commands/next-task.md create mode 100644 .claude/commands/validate.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/sce-atomic-commit/SKILL.md create mode 100644 .claude/skills/sce-bootstrap-context/SKILL.md create mode 100644 .claude/skills/sce-context-sync/SKILL.md create mode 100644 .claude/skills/sce-handover-writer/SKILL.md create mode 100644 .claude/skills/sce-plan-authoring/SKILL.md create mode 100644 .claude/skills/sce-plan-review/SKILL.md create mode 100644 .claude/skills/sce-task-execution/SKILL.md create mode 100644 .claude/skills/sce-validation/SKILL.md create mode 100644 cli/src/services/hooks/claude_transcript.rs create mode 100644 config/.claude/settings.json create mode 100644 context/sce/claude-raw-hook-capture.md diff --git a/.claude/agents/shared-context-code.md b/.claude/agents/shared-context-code.md new file mode 100644 index 00000000..b56786a0 --- /dev/null +++ b/.claude/agents/shared-context-code.md @@ -0,0 +1,60 @@ +--- +name: shared-context-code +description: Use when the user wants to execute one approved SCE task and sync context. +model: inherit +color: green +tools: ["Read", "Glob", "Grep", "Edit", "Write", "Skill", "AskUserQuestion", "Task", "Bash"] +--- + +You are the Shared Context Code agent. + +Mission +- Implement exactly one approved task from an existing plan. +- Validate behavior and keep `context/` aligned with the resulting code. + +Core principles +- The human owns architecture, risk, and final decisions. +- `context/` is durable AI-first memory and must stay current-state oriented. +- If context and code diverge, code is source of truth and context must be repaired. + +Hard boundaries +- One task per session unless the human explicitly approves multi-task execution. +- Do not change plan structure or reorder tasks without approval. +- If scope expansion is required, stop and ask. + +Authority inside `context/` +- You may create, update, rename, move, or delete files under `context/` as needed. +- You may create new top-level folders under `context/` when needed. +- Delete a file only if it exists and has no uncommitted changes. +- Use Mermaid when a diagram is needed. + +Startup +1) Confirm this session targets one approved plan task. +2) Proceed using the Procedure below. + +Procedure +- Load `sce-plan-review` and follow it exactly. +- Ask for explicit user confirmation that the reviewed task is ready for implementation. +- After confirmation, load `sce-task-execution` and follow it exactly. +- After implementation, load `sce-context-sync` and follow it. +- Wait for user feedback. +- If feedback requires in-scope fixes, apply the fixes, rerun light task-level checks/lints, run a build if it is light/fast, and run `sce-context-sync` again. +- If this is the final plan task, load `sce-validation` and follow it. + +Important behaviors +- Keep context optimized for future AI sessions, not prose-heavy narration. +- Do not leave completed-work summaries in core context files; represent resulting current state. +- After accepted implementation changes, context synchronization is part of done. +- Long-term quality is measured by code quality and context accuracy. + +Natural nudges to use +- "I will run `sce-plan-review` first to confirm the next task and clarify acceptance criteria." +- "Please confirm this task is ready for implementation, then I will execute it." +- "I will run light, task-level checks and lints first, and run a build too if it is light/fast." +- "After implementation, I will sync `context/`, wait for feedback, and resync if we apply fixes." + +Definition of done +- Code changes satisfy task acceptance checks. +- Relevant tests/checks are executed with evidence. +- Plan task status is updated. +- Context and code have no unresolved drift for this task. diff --git a/.claude/agents/shared-context-plan.md b/.claude/agents/shared-context-plan.md new file mode 100644 index 00000000..c81866e7 --- /dev/null +++ b/.claude/agents/shared-context-plan.md @@ -0,0 +1,66 @@ +--- +name: shared-context-plan +description: Use when the user needs to create or update an SCE plan before implementation. +model: inherit +color: blue +tools: ["Read", "Glob", "Grep", "Edit", "Write", "Skill", "AskUserQuestion", "Task", "Bash"] +--- + +You are the Shared Context Plan agent. + +Mission +- Convert a human change request into an implementation plan in `context/plans/`. +- Keep planning deterministic and reviewable. + +Core principles +- The human owns architecture, risk, and final decisions. +- `context/` is durable AI-first memory and must stay current-state oriented. +- If context and code diverge, code is source of truth and context must be repaired. + +Hard boundaries +- Never modify application code. +- Never run shell commands. +- Only write planning and context artifacts. +- Planning does not imply execution approval. + +Authority inside `context/` +- You may create, update, rename, move, or delete files under `context/` as needed. +- You may create new top-level folders under `context/` when needed. +- Delete a file only if it exists and has no uncommitted changes. +- Use Mermaid when a diagram is needed. + +Startup +1) Check for `context/`. +2) If missing, ask once for approval to bootstrap. +3) If approved, load `sce-bootstrap-context` and follow it. +4) If not approved, stop. +5) Read `context/context-map.md`, `context/overview.md`, and `context/glossary.md` if present. +6) Before broad exploration, consult `context/context-map.md` for relevant context files. +7) If context is partial or stale, continue with code truth and propose focused context repairs. + +Procedure +- Load `sce-plan-authoring` and follow it exactly. +- Ask targeted clarifying questions when requirements, boundaries, dependencies, or acceptance criteria are unclear. +- Write or update `context/plans/{plan_name}.md`. +- Confirm plan creation with `plan_name` and exact file path. +- Present the full ordered task list in chat, if it's written to a subagent print it in the main agent. +- Prompt the user to start a new session to implement `T01`. +- Provide one canonical next command: `/next-task {plan_name} T01`. + +Important behaviors +- Keep context optimized for future AI sessions, not prose-heavy narration. +- Do not leave completed-work summaries in core context files; represent resulting current state. +- Treat `context/plans/` as active execution artifacts; completed plans are disposable and not durable history. +- Promote durable outcomes into current-state context files and `context/decisions/` when needed. +- Long-term quality is measured by code quality and context accuracy. + +Natural nudges to use +- "Let me pull relevant files from `context/` before implementation." +- "Per SCE, chat-mode first, then implementation mode." +- "I will propose a plan with trade-offs first, then implement." +- "Now that this is settled, I will sync `context/` so future sessions stay aligned." + +Definition of done +- Plan has stable task IDs (`T01..T0N`). +- Each task has boundaries, done checks, and verification notes. +- Final task is always validation and cleanup. diff --git a/.claude/commands/change-to-plan.md b/.claude/commands/change-to-plan.md new file mode 100644 index 00000000..310ebb5b --- /dev/null +++ b/.claude/commands/change-to-plan.md @@ -0,0 +1,17 @@ +--- +description: "Use `sce-plan-authoring` to turn a change request into a scoped SCE plan" +allowed-tools: Task, Read, Glob, Grep, Edit, Write, Question, Skill +--- + +Load and follow the `sce-plan-authoring` skill. + +Input change request: +`$ARGUMENTS` + +Behavior: +- Keep this command as thin orchestration; detailed clarification handling, plan-shape rules, and task-writing behavior stay owned by `sce-plan-authoring`. +- Run `sce-plan-authoring` to resolve whether the input targets a new or existing plan, normalize goals/constraints/success criteria, and produce an implementation-ready task stack. +- Preserve the clarification gate from `sce-plan-authoring`: if blockers, ambiguity, or missing acceptance criteria remain, stop and ask the focused user questions needed to finish the plan safely. +- Require one-task/one-atomic-commit slicing through `sce-plan-authoring` before any task is considered ready for implementation. +- When the plan is ready, write or update `context/plans/{plan_name}.md`, confirm the resolved `{plan_name}` and exact path, and return the ordered task list. +- Stop after the planning handoff by providing the exact next-session command `/next-task {plan_name} T01`. diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 00000000..e64e0075 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,24 @@ +--- +description: "Use `sce-atomic-commit` to propose atomic commit message(s) from staged changes" +allowed-tools: Task, Read, Glob, Grep, Edit, Write, Question, Skill, Bash +--- + +Load and follow the `sce-atomic-commit` skill. + +Input: +`$ARGUMENTS` + +Behavior: +- If arguments are empty, treat input as unstated and infer commit intent from staged changes only. +- If arguments are provided, treat them as optional commit context to refine message proposals. +- Keep this command as thin orchestration; staged-diff analysis, atomic split decisions, and message wording stay owned by `sce-atomic-commit`. +- Before running `sce-atomic-commit`, explicitly stop and prompt the user: + + "Please run `git add ` for all changes you want included in this commit. + Atomic commits should only include intentionally staged changes. + Confirm once staging is complete." + +- After confirmation: + - Classify staged diff scope (`context/`-only vs mixed `context/` + non-`context/`) and apply the context-guidance gate from `sce-atomic-commit`. + - Run `sce-atomic-commit` to produce commit-message proposals and any needed split guidance. +- Do not create commits automatically; stop after returning proposed commit message(s) and split guidance when needed. diff --git a/.claude/commands/handover.md b/.claude/commands/handover.md new file mode 100644 index 00000000..0e91be08 --- /dev/null +++ b/.claude/commands/handover.md @@ -0,0 +1,15 @@ +--- +description: "Run `sce-handover-writer` to capture the current task for handoff" +allowed-tools: Task, Read, Glob, Grep, Edit, Write, Question, Skill +--- + +Load and follow the `sce-handover-writer` skill. + +Input: +`$ARGUMENTS` + +Behavior: +- Keep this command as thin orchestration; handover structure, naming, and content decisions stay owned by `sce-handover-writer`. +- Run `sce-handover-writer` to gather current task state, decisions made and rationale, open questions or blockers, and the next recommended step. +- Let `sce-handover-writer` create the handover in `context/handovers/`, using task-aligned naming such as `context/handovers/{plan_name}-{task_id}-{timestamp}.md` when the inputs support it. +- If required details are missing, infer only from current repo state, label assumptions clearly, then stop after reporting the exact handover path. diff --git a/.claude/commands/next-task.md b/.claude/commands/next-task.md new file mode 100644 index 00000000..b74c996a --- /dev/null +++ b/.claude/commands/next-task.md @@ -0,0 +1,24 @@ +--- +description: "Run `sce-plan-review` -> `sce-task-execution` -> `sce-context-sync` for one approved SCE task" +allowed-tools: Task, Read, Glob, Grep, Edit, Write, Question, Skill, Bash +--- + +Load and follow `sce-plan-review`, then `sce-task-execution`, then `sce-context-sync`. + +Input: +`$ARGUMENTS` + +Expected arguments: +- plan name or plan path (required) +- task ID (`T0X`) (optional) + +Behavior: +- Keep this command as thin orchestration; skill-owned review, implementation, validation, and context-sync details stay in the referenced skills. +- Run `sce-plan-review` first to resolve the plan target, choose the task, and report readiness. +- Apply the readiness confirmation gate from `sce-plan-review` before implementation: + - auto-pass only when both plan + task ID are provided and review reports no blockers, ambiguity, or missing acceptance criteria + - otherwise resolve the open points and ask the user to confirm the task is ready before continuing +- Run `sce-task-execution` next; keep the mandatory implementation stop, scoped edits, light checks/lints/build, and plan status updates skill-owned. +- After implementation, run `sce-context-sync` as the required done gate and wait for user feedback. +- If feedback requires in-scope fixes, apply the fixes, rerun light checks (and a light/fast build when applicable), then run `sce-context-sync` again. +- If this was the final plan task, run `sce-validation`; otherwise stop after prompting a new session with `/next-task {plan_name} T0X`. diff --git a/.claude/commands/validate.md b/.claude/commands/validate.md new file mode 100644 index 00000000..ece8cebc --- /dev/null +++ b/.claude/commands/validate.md @@ -0,0 +1,15 @@ +--- +description: "Run `sce-validation` to finish an SCE plan with validation and cleanup" +allowed-tools: Task, Read, Glob, Grep, Edit, Write, Question, Skill, Bash +--- + +Load and follow the `sce-validation` skill. + +Input: +`$ARGUMENTS` + +Behavior: +- Keep this command as thin orchestration; validation scope, command selection, cleanup, and evidence formatting stay owned by `sce-validation`. +- Run `sce-validation` to execute the full validation phase for the targeted plan or change, including required checks, evidence capture, and cleanup expected by the skill. +- Let `sce-validation` decide pass/fail status and record any residual risks or unmet criteria. +- Stop after reporting the validation outcome and the location of any written validation evidence. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..52ac4f0f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,65 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "SessionStart" + ] + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "UserPromptSubmit" + ] + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "PostToolUse" + ] + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "Stop" + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/sce-atomic-commit/SKILL.md b/.claude/skills/sce-atomic-commit/SKILL.md new file mode 100644 index 00000000..f320eb31 --- /dev/null +++ b/.claude/skills/sce-atomic-commit/SKILL.md @@ -0,0 +1,90 @@ +--- +name: sce-atomic-commit +description: | + Write atomic, repo-style git commits from a change summary or diff. Use when preparing commit messages, splitting work into coherent commits, or reviewing whether a commit is too broad. +compatibility: claude +--- + +## Goal + +Turn the current staged changes into atomic repository-style commit message proposals. + +For this workflow: +- analyze the staged diff to identify coherent change units +- propose one or more commit messages when staged changes mix unrelated goals +- keep each proposed message focused on a single coherent change +- stay proposal-only: do not create commits automatically + +## Inputs + +Accept any of: +- staged diff (preferred) +- changed file list with notes +- PR/task summary +- before/after behavior notes + +## Output format + +Produce commit message proposals that follow: +- `scope: Subject` +- imperative verb (Fix/Add/Remove/Implement/Refactor/Simplify/Rename/Update/Ensure/Allow) +- no trailing period in subject +- body when context is needed (why/what changed/impact) +- issue references on their own lines (for example `Fixes #123`) + +When staged changes include `context/plans/*.md`, each commit body must also include: +- affected plan slug(s) +- updated task ID(s) (`T0X`) + +If staged `context/plans/*.md` changes do not expose the plan slug or updated task ID clearly enough to cite faithfully, stop and ask for clarification instead of inventing references. + +## Procedure + +1) Analyze the staged diff for coherent units +- Infer the main reason(s) for the staged change from the diff first. +- Use optional notes only to refine wording, not to override the staged truth. +- Identify whether staged changes represent one coherent unit or multiple unrelated goals. + +2) Choose scope for each unit +- Use the smallest stable subsystem/module name recognizable in the repo. +- If unclear, use the primary directory/package of the change. + +3) Write subject for each unit +- Pattern: `: ` +- Keep concrete and targeted. + +4) Add body when needed +- Explain what was wrong/missing, why it matters, what changed conceptually, and impact. +- Add issue references on separate lines. + +5) Apply the plan-update body rule when needed +- Check whether staged changes include `context/plans/*.md`. +- If yes, cite the affected plan slug(s) and updated task ID(s) in the body. +- If the staged plan diff is ambiguous, stop with actionable guidance asking the user to stage or clarify the plan/task reference explicitly. + +6) Propose split guidance when appropriate +- If staged changes mix unrelated goals (for example: a feature change plus unrelated refactoring), propose separate commit messages for each coherent unit. +- Explain why the split is recommended and which files belong to each proposed commit. +- If staged changes represent one coherent unit, propose a single commit message. + +7) Validate each proposed message +- Each message should describe its intended change faithfully. +- The subject should stay concise and technical. +- The body should add useful why/impact context instead of repeating the subject. +- Do not invent plan or task references. + +## Context-file guidance gating + +- Check staged diff scope before proposing commit messaging guidance. +- If staged changes are context-only (`context/**`), context-file-focused guidance is allowed. +- If staged changes are mixed (`context/**` + non-`context/**`), avoid default context-file commit reminders and prioritize guidance that reflects the full staged scope. + +## Anti-patterns + +- vague subjects ("cleanup", "updates") +- body repeats subject without adding why +- playful tone in serious fixes/architecture changes +- mention `context/` sync activity in commit messages +- inventing plan slugs or task IDs for staged plan edits +- proposing splits for changes that are already coherent +- forcing unrelated changes into a single commit diff --git a/.claude/skills/sce-bootstrap-context/SKILL.md b/.claude/skills/sce-bootstrap-context/SKILL.md new file mode 100644 index 00000000..2981a743 --- /dev/null +++ b/.claude/skills/sce-bootstrap-context/SKILL.md @@ -0,0 +1,57 @@ +--- +name: sce-bootstrap-context +description: | + Creates the SCE (Shared Context Engineering) baseline `context/` directory structure — a set of markdown files and sub-folders used as shared project memory (overview, architecture, patterns, glossary, decisions, plans, handovers, and a temporary scratch space). Use when the `context/` folder is missing from the repository, when a user asks to initialise the project context, set up context, create baseline documentation structure, or when shared configuration files for project memory are absent. +compatibility: claude +--- + +## When to use +- Use only when `context/` is missing. +- Ask for human approval before creating files. + +## Required baseline +Create these paths: +- `context/overview.md` +- `context/architecture.md` +- `context/patterns.md` +- `context/glossary.md` +- `context/context-map.md` +- `context/plans/` +- `context/handovers/` +- `context/decisions/` +- `context/tmp/` +- `context/tmp/.gitignore` + +Use the following commands to create the directory structure: +```bash +mkdir -p context/plans context/handovers context/decisions context/tmp +touch context/overview.md context/architecture.md context/patterns.md context/glossary.md context/context-map.md +``` + +`context/tmp/.gitignore` content: +``` +* +!.gitignore +``` + +## Validation +After running the commands, verify all expected paths exist before proceeding: +```bash +ls context/overview.md context/architecture.md context/patterns.md context/glossary.md context/context-map.md context/plans context/handovers context/decisions context/tmp context/tmp/.gitignore +``` +If any path is missing, re-create it before moving on. + +## No-code bootstrap rule +- If the repository has no application code, keep `overview.md`, `architecture.md`, `patterns.md`, and `glossary.md` empty or placeholder-only. +- Do not invent implementation details. + +Example placeholder content for empty files in a no-code repo: +```markdown +# Overview + +> This section has not been populated yet. Add a high-level description of the project here. +``` + +## After bootstrapping +- Add baseline links in `context/context-map.md`. +- Tell the user that `context/` should be committed as shared memory. diff --git a/.claude/skills/sce-context-sync/SKILL.md b/.claude/skills/sce-context-sync/SKILL.md new file mode 100644 index 00000000..199413fc --- /dev/null +++ b/.claude/skills/sce-context-sync/SKILL.md @@ -0,0 +1,93 @@ +--- +name: sce-context-sync +description: | + Use when user wants to update project documentation to reflect code changes, sync docs with code, refresh project context, or keep AI memory files accurate after completing an implementation task. Scans modified code, classifies the change significance, then updates or verifies Markdown context files under `context/` (overview, architecture, glossary, patterns, context-map, and domain files) so that durable AI memory stays aligned with current code truth. +compatibility: claude +--- + +## Principle +- Context is durable AI memory and must reflect current-state truth. +- If context and code diverge, code is source of truth. + +## Mandatory sync pass (important-change gated) +For every completed implementation task, run a sync pass over these shared files: +- `context/overview.md` +- `context/architecture.md` +- `context/glossary.md` +- `context/patterns.md` +- `context/context-map.md` + +Classify whether the task is an important change before deciding to edit or verify root context files. + +## Root context significance gating +- **Root edits required** - task introduces cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology changes. +- **Verify-only** - task is localized to a single feature/domain with no root-level behavior, architecture, or terminology impact. Keep root files unchanged; capture details in domain files instead. +- Even when verify-only, still verify `context/overview.md`, `context/architecture.md`, and `context/glossary.md` against code truth before declaring done. + +## Step-by-step sync pass workflow + +1. **Classify the change** - Important change or verify-only (see [Classification Reference](#classification-reference) below). +2. **Read the affected code** - Review modified files to understand what actually changed. +3. **Verify root files** - Open `context/overview.md`, `context/architecture.md`, and `context/glossary.md`; confirm they match code truth. +4. **Edit or skip root files** - Important change: update relevant root files. Verify-only: leave root files unchanged. +5. **Create or update domain files** - Write or revise `context/{domain}/` files for feature-specific detail (see [Domain File Policy](#domain-file-creation-policy) below). +6. **Ensure feature existence** - Every newly implemented feature must have at least one durable canonical description discoverable from context (domain file or `context/overview.md` for cross-cutting features). +7. **Update `context/context-map.md`** - Add or refresh discoverability links to any new or changed context files. +8. **Add glossary entries** - For any new domain language introduced by the task. +9. **Final check** - Confirm all updated files are <= 250 lines, diagrams are present where needed, and links use relative paths. + +### Before/after example +A task adds a new `PaymentGateway` abstraction used only in the payments domain (verify-only - domain-local). + +**`context/glossary.md`** - unchanged (no new root-level terminology). + +**New file: `context/payments/payment-gateway.md`:** +```markdown +# PaymentGateway + +Abstraction over external payment processors (Stripe, Adyen). +Defined in `src/payments/gateway/`. + +## Contract +- `charge(amount, token): Result` +- `refund(chargeId): Result` + +See also: [overview.md](../overview.md), [context-map.md](../context-map.md) +``` + +**`context/context-map.md`** - updated with a link to `context/payments/payment-gateway.md`. + +--- + +## Classification Reference + +| Important change (root edits required) | Verify-only (root files unchanged) | +|---|---| +| New auth strategy replacing existing one - architecture + terminology | New field on an existing API response - localized, no architecture impact | +| Background job queue used across multiple domains - cross-cutting | Bug fix in a single service's retry logic - no new root-level behavior | +| Renaming a core concept (e.g., `Order` -> `Purchase`) - canonical terminology | New UI component added to an existing feature - no cross-cutting impact | + +--- + +## Domain File Creation Policy + +- Use `context/{domain}/` for detailed feature behavior. +- If a feature does not cleanly fit an existing domain file, create a new one - do not defer documentation. +- If the feature appears to be part of a larger future domain, document the implemented slice now in a focused file and link it to related context. +- Prefer a small, precise domain file over overloading `overview.md` with detail. +- If updates for the current feature/domain outgrow shared files, migrate detail into `context/{domain}/` files, keep concise pointers in shared files, and add discoverability links in `context/context-map.md`. + +--- + +## Final-task requirement +- In the final plan task (validation/cleanup), confirm feature existence documentation is present and linked. +- If a feature was implemented but not represented in context, add the missing entry before declaring the task done. + +## Quality constraints +- One topic per file. +- Prefer concise current-state documentation over narrative changelogs. +- Link related context files with relative paths. +- Include concrete code examples when needed to clarify non-trivial behavior. +- Every context file must stay at or below 250 lines; if it would exceed 250, split into focused files and link them. +- Add a Mermaid diagram when structure, boundaries, or flows are complex. +- Ensure major code areas have matching context coverage. diff --git a/.claude/skills/sce-handover-writer/SKILL.md b/.claude/skills/sce-handover-writer/SKILL.md new file mode 100644 index 00000000..07a14634 --- /dev/null +++ b/.claude/skills/sce-handover-writer/SKILL.md @@ -0,0 +1,48 @@ +--- +name: sce-handover-writer +description: | + Creates a structured handover document summarizing task context, decisions made, open questions, and recommended next steps — saved to `context/handovers/`. Use when a user wants to hand off, transition, or pass a task to someone else, create handover notes, write a task transition document, or capture current progress for a future session. Trigger phrases include "create a handover", "hand this off", "write handover notes", "pass this task on", or "document where I'm up to". +compatibility: claude +--- + +## What I do +- Create a new handover file in `context/handovers/`. +- Capture: + - current task state + - decisions made and rationale + - open questions or blockers + - next recommended step + +## How to run this + +1. **Gather context** - review the current task, recent changes, and repo state. +2. **Create the file** - use task-aligned naming: `context/handovers/{plan_name}-{task_id}.md`. +3. **Fill each section** - follow the template below, labelling any inferred details as assumptions. +4. **Verify completeness** - confirm all four sections are populated before finishing. + +If key details are missing, infer from repo state and clearly label assumptions. + +## Handover document template + +```markdown +# Handover: {plan_name} - {task_id} + +## Current Task State +- What was being worked on and how far along it is. +- e.g. "Implementing OAuth login flow; token generation complete, redirect handling in progress." + +## Decisions Made +- Key choices and their rationale. +- e.g. "Chose JWT over session cookies for statelessness. Rejected library X due to licence constraints." + +## Open Questions / Blockers +- Unresolved issues or outstanding dependencies. +- e.g. "Awaiting confirmation on token expiry policy from product team." + +## Next Recommended Step +- The single most important action for whoever picks this up. +- e.g. "Complete the redirect handler in `src/auth/callback.ts`, then run the auth integration tests." +``` + +## Expected output +- A complete handover document in `context/handovers/` using task-aligned naming when possible. diff --git a/.claude/skills/sce-plan-authoring/SKILL.md b/.claude/skills/sce-plan-authoring/SKILL.md new file mode 100644 index 00000000..3f1af5d9 --- /dev/null +++ b/.claude/skills/sce-plan-authoring/SKILL.md @@ -0,0 +1,89 @@ +--- +name: sce-plan-authoring +description: | + Creates or updates structured SCE (Shared Context Engine) implementation plans saved to `context/plans/{plan_name}.md`. Breaks a change request into scoped, atomic tasks with clear goals, boundaries, acceptance criteria, and verification steps. Use when a user wants to plan a new feature, refactor, or integration; needs a project plan, task breakdown, implementation roadmap, or work plan; or describes a change with success criteria that requires structured planning before execution. +compatibility: claude +--- + +## Goal +Turn a human change request into `context/plans/{plan_name}.md`. + +## Intake trigger +- If a request includes both a change description and success criteria, planning is mandatory before implementation. +- Planning does not imply execution approval. + +## Clarification gate (blocking) +- Before writing or updating any plan, run an ambiguity check. +- If any critical detail is unclear, ask 1-3 targeted questions and stop. +- Do not write or update `context/plans/{plan_name}.md` until the user answers. +- Critical details that must be resolved before planning include: + - scope boundaries and out-of-scope items + - success criteria and acceptance signals + - constraints and non-goals + - dependency choices (new libs/services, versions, and integration approach) + - domain ambiguity (unclear business rules, terminology, or ownership) + - architecture concerns (patterns, interfaces, data flow, migration strategy, and risk tradeoffs) + - task ordering assumptions and prerequisite sequencing +- Do not silently invent missing requirements. +- If the user explicitly allows assumptions, record them in an `Assumptions` section. +- Incorporate user answers into the plan before handoff. + +Example clarification questions (use this style - specific, blocking, targeted): +> 1. Should the new endpoint authenticate via the existing JWT middleware, or is a separate auth flow in scope? +> 2. Is database migration rollback a hard requirement, or is forward-only acceptable for this change? +> 3. Which service owns the `UserProfile` type - should this task modify that definition or only consume it? + +## Plan format +1) Change summary +2) Success criteria +3) Constraints and non-goals +4) Task stack (`T01..T0N`) +5) Open questions (if any) + +## Task format (required) +For each task include: +- Task ID +- Goal +- Boundaries (in/out of scope) +- Done when +- Verification notes (commands or checks) + +## Atomic task slicing contract (required) +- Author each executable task as one atomic commit unit by default. +- Every task must be scoped so one contributor can complete it and land it as one coherent commit without bundling unrelated changes. +- If a candidate task would require multiple independent commits (for example: refactor + behavior change + docs), split it into separate sequential tasks before finalizing the plan. +- Keep broad wrappers (`polish`, `finalize`, `misc updates`) out of executable tasks; convert them into specific outcomes with concrete acceptance checks. + +Example compliant skeleton: +- [ ] T0X: `[single intent title]` (status:todo) + - Task ID: T0X + - Goal: `[one outcome]` + - Boundaries (in/out of scope): `[tight scope]` + - Done when: `[clear acceptance for one coherent change]` + - Verification notes (commands or checks): `[targeted checks for this change]` + +Example filled-in task entry: +- [ ] T02: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T02 + - Goal: Implement a POST `/auth/refresh` endpoint that exchanges a valid refresh token for a new access token. + - Boundaries (in/out of scope): In - route handler, token validation logic, response schema. Out - refresh token rotation policy (covered in T03), client-side storage changes. + - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired/invalid token; unit tests pass; OpenAPI spec updated. + - Verification notes (commands or checks): `pnpm test src/auth/refresh.test.ts`; `curl -X POST localhost:3000/auth/refresh -d '{"token":"..."}' -w "%{http_code}"`. + +Use checkbox lines for machine-friendly progress tracking: +- `- [ ] T01: ... (status:todo)` + +## Complete plan example + +See `context/plans/PLAN_EXAMPLE.md` for a full annotated reference plan (JWT auth walkthrough covering all required sections and four task entries). + +## Required final task +- Final task is always validation and cleanup. +- It must include full checks and context sync verification. + +## Output contract +- Save plan under `context/plans/`. +- Confirm plan creation with `plan_name` and exact file path. +- Present the full ordered task list in chat. +- Prompt the user to start a new session with Shared Context Code agent to implement `T01`. +- Provide one canonical next command: `/next-task {plan_name} T01`. diff --git a/.claude/skills/sce-plan-review/SKILL.md b/.claude/skills/sce-plan-review/SKILL.md new file mode 100644 index 00000000..69cf6ba0 --- /dev/null +++ b/.claude/skills/sce-plan-review/SKILL.md @@ -0,0 +1,91 @@ +--- +name: sce-plan-review +description: | + Reviews an existing SCE plan file (a Markdown checklist in `context/plans/`) to identify the next unchecked task, surface blockers or ambiguous acceptance criteria, and produce an explicit readiness verdict before implementation begins. Use when the user wants to continue a plan, resume work, pick the next step, or check what remains in an active plan — e.g. "continue the plan", "what's next?", "resume work on the plan", "review my plan and prepare the next task". +compatibility: claude +--- + +## What I do +- Continue execution from an existing plan in `context/plans/`. +- Read the selected plan and identify the next task from the first unchecked checkbox. +- Ask focused questions for anything not clear enough to execute safely. + +## How to run this +- Use this skill when the user asks to continue a plan or pick the next task. +- If `context/` is missing, ask once: "`context/` is missing. Bootstrap SCE baseline now?" + - If yes, create baseline with `sce-bootstrap-context` and continue. + - If no, stop and explain SCE workflows require `context/`. +- Read `context/context-map.md`, `context/overview.md`, and `context/glossary.md` before broad exploration. +- Resolve plan target: + - If plan path argument exists, use it. + - If multiple plans exist and no explicit path is provided, ask user to choose. +- Collect: + - completed tasks + - next task + - blockers, ambiguity, and missing acceptance criteria +- Prompt user to resolve unclear points before implementation. +- Confirm scope explicitly for this session: one task by default unless user requests multi-task execution. + +## Plan file format +SCE plans are Markdown files stored in `context/plans/`. Tasks are tracked as checkboxes: + +```markdown +# Plan: Add user authentication + +## Tasks +- [x] Scaffold auth module +- [x] Add password hashing utility +- [ ] Implement login endpoint <- next task (first unchecked) +- [ ] Write integration tests +- [ ] Update context/current-state.md +``` + +The first unchecked `- [ ]` item is the next task to review and prepare. + +## Rules +- Do not auto-mark tasks complete during review. +- Keep continuation state in the plan markdown itself. +- Treat `context/plans/` as active execution artifacts; completed plans are disposable and not a durable context source. +- If durable history is needed, record it in current-state context files and/or `context/decisions/` instead of completed plan files. +- Keep implementation blocked until decision alignment on unclear points. +- If plan context is stale or partial, continue with code truth and flag context updates. + +## Expected output + +Produce a structured readiness summary after review: + +``` +## Plan Review - [plan filename] + +**Completed tasks:** 2 of 5 +**Next task:** Implement login endpoint + +**Acceptance criteria:** +- POST /auth/login returns JWT on success +- Returns 401 on invalid credentials + +**Issues found:** +- Blocker: JWT secret source not specified (env var? config file?) +- Ambiguity: Should failed attempts be rate-limited in this task or a later one? + +**ready_for_implementation: no** + +**Required decisions before proceeding:** +1. Confirm JWT secret source +2. Confirm rate-limiting scope +``` + +When all issues are resolved: + +``` +**ready_for_implementation: yes** +Proceeding with: Implement login endpoint +``` + +- Explicit readiness verdict: `ready_for_implementation: yes|no`. +- If not ready, explicit issue categories: blockers, ambiguity, missing acceptance criteria. +- Explicit user-aligned decisions needed to proceed to implementation. +- Explicit user confirmation request that the task is ready for implementation when unresolved issues remain. + +## Related skills +- `sce-bootstrap-context` - creates the `context/` baseline required by this skill diff --git a/.claude/skills/sce-task-execution/SKILL.md b/.claude/skills/sce-task-execution/SKILL.md new file mode 100644 index 00000000..d90a14fb --- /dev/null +++ b/.claude/skills/sce-task-execution/SKILL.md @@ -0,0 +1,58 @@ +--- +name: sce-task-execution +description: | + Executes a single planned implementation task with a mandatory approval gate, scope guardrails, and evidence capture. Use when a user wants to implement, run, or execute a specific task from a project plan — such as coding a feature, applying a patch, or making targeted file changes — while enforcing explicit scope boundaries, a pre-implementation confirmation prompt, test/lint verification, and status tracking in context/plans/{plan_id}.md. +compatibility: claude +--- + +## Scope rule +- Execute exactly one task per session by default. +- If multi-task execution is requested, confirm explicit human approval. + +## Mandatory implementation stop +- Before writing or modifying any code, pause and prompt the user. +- The prompt must explain: + - task goal + - boundaries (in/out of scope) + - done checks + - expected files/components to change + - key approach, trade-offs, and risks +- Then ask explicitly whether to continue. +- Do not edit files, generate code, or apply patches until the user confirms. + +**Example mandatory stop prompt:** +``` +Task goal: Add input validation to the user registration endpoint. +In scope: src/routes/register.ts, src/validators/user.ts +Out of scope: Auth logic, database schema, frontend forms +Done checks: All existing tests pass; new validation tests cover empty/invalid email and short passwords +Expected changes: ~2 files modified, no new dependencies +Approach: Use the existing `validateSchema` helper; add a Zod schema for registration payload +Trade-offs: Zod adds minor overhead; keeps validation consistent with other routes +Risks: Existing callers that omit optional fields may start failing validation + +Continue with implementation now? (yes/no) +``` + +## Required sequence +1) Restate task goal, boundaries, done checks, and expected file touch scope. +2) Propose approach, trade-offs, and risks. +3) Stop and ask: "Continue with implementation now?" (yes/no). +4) Implement minimal in-scope changes. +5) Run light task-level tests/checks and lints first, and run a build when the build is light/fast (targeted over full-suite unless requested), then capture evidence. +6) Record whether the implementation is an important change for context sync (root-edit required) or verify-only (no root edits expected). +7) Keep session-only scraps in `context/tmp/`. +8) Update task status in `context/plans/{plan_id}.md`. + +**Example task status update (`context/plans/{plan_id}.md`):** +```markdown +## Task: Add input validation to registration endpoint +- **Status:** done +- **Completed:** 2025-06-10 +- **Files changed:** src/routes/register.ts, src/validators/user.ts +- **Evidence:** 14/14 tests passed, lint clean, build succeeded (12s) +- **Notes:** Zod schema added; no breaking changes to existing callers +``` + +## Scope expansion rule +- If out-of-scope edits are needed, stop and ask for approval. diff --git a/.claude/skills/sce-validation/SKILL.md b/.claude/skills/sce-validation/SKILL.md new file mode 100644 index 00000000..22068f7f --- /dev/null +++ b/.claude/skills/sce-validation/SKILL.md @@ -0,0 +1,47 @@ +--- +name: sce-validation +description: | + Runs the final validation phase of a project plan by executing the full test suite, lint and format checks, removing temporary scaffolding, and writing a structured validation report with command outputs and success-criteria evidence to `context/plans/{plan_name}.md`. Use when the user wants to verify a completed implementation, confirm all success criteria are met, wrap up a plan, finalize a feature or fix, or sign off on a change before closing it out. +compatibility: claude +--- + +## When to use +- Use for the plan's final validation task after implementation is complete. +- Triggered by requests like "validate the plan", "run final checks", "confirm everything passes", "wrap up the task", or "sign off on this change". + +## Validation checklist +1) **Run full test suite** - discover and run the project's primary test command (e.g., `pytest`, `npm test`, `go test ./...`, `cargo test`, `make test`). Check `package.json`, `Makefile`, `pyproject.toml`, or CI config files to find the right command. +2) **Run lint/format checks** - discover and run the project's lint and format tools (e.g., `eslint`, `ruff`, `golangci-lint`, `cargo clippy`, `make lint`). Check project config files such as `.eslintrc`, `pyproject.toml`, or `.golangci.yml`. +3) **Remove temporary scaffolding** - delete any debug code, temporary files, or intermediate artifacts introduced during the change. +4) **Verify context reflects final implemented behavior** - confirm that plan context and notes match the actual final state of the implementation. +5) **Confirm each success criterion has evidence** - for every success criterion defined in the plan, record concrete evidence (command output, exit code, screenshot reference, or file path). + +### If checks fail +- **Fixable failures**: fix the issue, re-run the failing check, and update the report with the corrected output. +- **Non-trivial failures**: document the failure, the attempted fix, and the blocker in the report under "Failed checks and follow-ups". Escalate to the user before closing out. +- **Lint/format auto-fixes**: if the tool supports auto-fix (e.g., `ruff --fix`, `eslint --fix`), apply it, then re-run to confirm clean output. + +## Validation report +Write to `context/plans/{plan_name}.md` including: +- Commands run +- Exit codes and key outputs +- Failed checks and follow-ups +- Success-criteria verification summary +- Residual risks, if any + +### Example report entry +``` +## Validation Report + +### Commands run +- `npm test` -> exit 0 (42 tests passed, 0 failed) +- `eslint src/` -> exit 0 (no warnings) +- Removed: `src/debug_patch.js` (temporary scaffolding) + +### Success-criteria verification +- [x] All API endpoints return 200 for valid input -> confirmed via test output line 34 +- [x] Error responses include structured JSON -> confirmed via `test_error_format.js` + +### Residual risks +- None identified. +``` diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 6d89a53d..2e02531c 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3374,6 +3374,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "tokio", "tracing", "turso", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 43e30545..836e4a64 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -58,6 +58,9 @@ apple-native-keyring-store = { version = "1", features = ["keychain"] } [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = "1" +[dev-dependencies] +tempfile = "3" + [build-dependencies] sha2 = "0.11" diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index caa08b37..b39b56cf 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -279,6 +279,16 @@ pub enum HooksSubcommand { #[command(about = "Run diff-trace hook (reads JSON payload from STDIN)")] DiffTrace, + + #[command( + name = "claude-capture", + about = "Capture raw Claude hook payload JSON from STDIN", + hide = true + )] + ClaudeCapture { + #[arg(value_name = "event-name")] + event_name: String, + }, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index e7ac7d0f..2a6e456c 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -375,6 +375,7 @@ pub(crate) mod context_dir { pub const DECISIONS: &str = "decisions"; pub const HANDOVERS: &str = "handovers"; pub const TMP: &str = "tmp"; + pub const CLAUDE: &str = "claude"; } #[allow(dead_code)] @@ -465,6 +466,10 @@ impl RepoPaths { self.context_dir().join(context_dir::TMP) } + pub(crate) fn claude_capture_tmp_dir(&self) -> PathBuf { + self.context_tmp_dir().join(context_dir::CLAUDE) + } + pub(crate) fn context_overview_file(&self) -> PathBuf { self.context_dir().join(context_file::OVERVIEW) } diff --git a/cli/src/services/hooks/claude_transcript.rs b/cli/src/services/hooks/claude_transcript.rs new file mode 100644 index 00000000..13f2620a --- /dev/null +++ b/cli/src/services/hooks/claude_transcript.rs @@ -0,0 +1,85 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use serde_json::Value; + +/// Extract the model identity from a Claude JSONL transcript by matching an +/// assistant message whose `tool_use` content block has the given `tool_use_id`. +/// +/// Returns `None` when: +/// - the transcript file is missing or unreadable +/// - any JSONL line is malformed +/// - no assistant message with a matching `tool_use` content block is found +/// - the matching assistant message has no `model` field or it is not a string +/// +/// Extracted from the capture flow in T02 to enrich `PostToolUse` capture artifacts. +pub fn extract_claude_transcript_model( + transcript_path: &Path, + tool_use_id: &str, +) -> Option { + let file = File::open(transcript_path).ok()?; + let reader = BufReader::new(file); + + for line in reader.lines() { + let line = line.ok()?; + if line.trim().is_empty() { + continue; + } + + let parsed: Value = serde_json::from_str(&line).ok()?; + let obj = parsed.as_object()?; + + // Determine whether this line represents an assistant message. + // The actual Claude transcript wraps the message inside a "message" envelope: + // {"type":"assistant","message":{"role":"assistant","model":"...","content":[...]}} + // Fall back to top-level "role" for simpler/legacy JSONL formats. + let msg = if let Some(msg_obj) = obj.get("message").and_then(|m| m.as_object()) { + let is_assistant = obj + .get("type") + .and_then(|t| t.as_str()) + .is_some_and(|t| t == "assistant") + || msg_obj + .get("role") + .and_then(|r| r.as_str()) + .is_some_and(|r| r == "assistant"); + if !is_assistant { + continue; + } + msg_obj + } else { + // Flat format: {"role":"assistant","model":"...","content":[...]} + match obj.get("role").and_then(|r| r.as_str()) { + Some("assistant") => {} + _ => continue, + } + obj + }; + + // Scan content blocks for a matching tool_use id. + let Some(content) = msg.get("content").and_then(|c| c.as_array()) else { + continue; + }; + let has_match = content.iter().any(|block| { + block + .as_object() + .and_then(|b| b.get("type")) + .and_then(|t| t.as_str()) + .is_some_and(|t| t == "tool_use") + && block + .as_object() + .and_then(|b| b.get("id")) + .and_then(|id| id.as_str()) + .is_some_and(|id| id == tool_use_id) + }); + + if !has_match { + continue; + } + + // Return the model field from the matching assistant message. + return msg.get("model").and_then(|m| m.as_str()).map(String::from); + } + + None +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index ae69d393..215a3b31 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -9,6 +9,7 @@ use anyhow::{anyhow, bail, Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; use serde_json::{json, to_string as serialize_to_json, Value}; +use tracing; use crate::services::agent_trace::{ build_agent_trace, validate_agent_trace_value, AgentTrace, AgentTraceMetadataInput, @@ -18,13 +19,14 @@ use crate::services::agent_trace_db::{ AgentTraceDb, AgentTraceInsert, DiffTraceInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, }; -use crate::services::config; use crate::services::observability::traits::Logger; use crate::services::patch::{ combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, parse_patch as parse_patch_from_text, ParsedPatch, }; +use crate::services::{config, default_paths::RepoPaths}; +pub mod claude_transcript; pub mod command; pub mod lifecycle; @@ -33,6 +35,45 @@ pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE Result { + let normalized = event_name.trim(); + + match normalized { + "SessionStart" => Ok(Self::SessionStart), + "UserPromptSubmit" => Ok(Self::UserPromptSubmit), + "PostToolUse" => Ok(Self::PostToolUse), + "Stop" => Ok(Self::Stop), + _ => bail!(unsupported_claude_capture_event_error(normalized)), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "SessionStart", + Self::UserPromptSubmit => "UserPromptSubmit", + Self::PostToolUse => "PostToolUse", + Self::Stop => "Stop", + } + } +} + +fn unsupported_claude_capture_event_error(event_name: &str) -> String { + format!( + "Unsupported Claude capture event '{event_name}'. Supported events: {SUPPORTED_CLAUDE_CAPTURE_EVENTS_TEXT}. Try: rerun with one of the supported Claude hook event names." + ) +} #[derive(Clone, Debug, Eq, PartialEq)] pub enum HookSubcommand { @@ -48,6 +89,9 @@ pub enum HookSubcommand { rewrite_method: String, }, DiffTrace, + ClaudeCapture { + event: ClaudeCaptureEvent, + }, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -61,6 +105,14 @@ struct DiffTracePayload { tool_version: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct TraceArtifactPayload { + trace_directory: PathBuf, + trace_name: String, + serialized: String, + artifact_description: &'static str, +} + /// Required `sce hooks diff-trace` STDIN payload shape: /// `{ sessionID, diff, time, model_id, tool_name, tool_version }`. /// @@ -100,6 +152,9 @@ fn run_hooks_subcommand_in_repo( run_post_rewrite_subcommand_with_trace(repository_root, subcommand, rewrite_method) } HookSubcommand::DiffTrace => run_diff_trace_subcommand(repository_root, logger), + HookSubcommand::ClaudeCapture { event } => { + run_claude_capture_subcommand(repository_root, *event, logger) + } } } @@ -159,6 +214,142 @@ fn run_diff_trace_subcommand_from_payload( } } +fn run_claude_capture_subcommand( + repository_root: &Path, + event: ClaudeCaptureEvent, + logger: Option<&dyn Logger>, +) -> Result { + let stdin_payload = read_hook_stdin()?; + let result = run_claude_capture_subcommand_from_payload(repository_root, event, &stdin_payload); + if let Err(ref error) = result { + if let Some(log) = logger { + log.error( + "sce.hooks.claude_capture.error", + &error.to_string(), + &[("event", event.as_str())], + ); + } + } + result +} + +fn run_claude_capture_subcommand_from_payload( + repository_root: &Path, + event: ClaudeCaptureEvent, + stdin_payload: &str, +) -> Result { + persist_claude_capture_payload(repository_root, event, stdin_payload)?; + + Ok(format!( + "claude-capture hook intake persisted {} payload to context/tmp/claude.", + event.as_str() + )) +} + +fn persist_claude_capture_payload( + repository_root: &Path, + event: ClaudeCaptureEvent, + stdin_payload: &str, +) -> Result { + persist_claude_capture_payload_with(repository_root, event, stdin_payload, |artifact| { + persist_serialized_trace_payload( + &artifact.trace_directory, + &artifact.trace_name, + &artifact.serialized, + artifact.artifact_description, + ) + }) +} + +fn persist_claude_capture_payload_with

( + repository_root: &Path, + event: ClaudeCaptureEvent, + stdin_payload: &str, + persist_artifact: P, +) -> Result +where + P: FnOnce(TraceArtifactPayload) -> Result, +{ + let artifact = build_claude_capture_artifact(repository_root, event, stdin_payload)?; + + persist_artifact(artifact) +} + +fn build_claude_capture_artifact( + repository_root: &Path, + event: ClaudeCaptureEvent, + stdin_payload: &str, +) -> Result { + let mut parsed = parse_claude_capture_payload(stdin_payload)?; + + // Enrich PostToolUse artifacts with the model identity from the Claude + // transcript. Other event types (SessionStart, UserPromptSubmit, Stop) + // remain unchanged. + if event == ClaudeCaptureEvent::PostToolUse { + enrich_post_tool_use_with_model(&mut parsed); + } + + let serialized = format!( + "{}\n", + serde_json::to_string_pretty(&parsed) + .context("Failed to serialize Claude capture payload for persistence.")? + ); + + Ok(TraceArtifactPayload { + trace_directory: RepoPaths::new(repository_root).claude_capture_tmp_dir(), + trace_name: event.as_str().to_string(), + serialized, + artifact_description: "Claude capture payload", + }) +} + +/// Attempt to enrich a `PostToolUse` parsed payload with the model identity +/// from the Claude JSONL transcript. Reads `transcript_path` and `tool_use_id` +/// from the payload, calls the transcript extraction helper, and injects +/// the model as `"model"` in the root JSON object. +/// +/// Degrades gracefully: if `transcript_path` or `tool_use_id` are missing, +/// the transcript is inaccessible, or the model cannot be determined, the +/// payload is left unchanged. A warning is logged via `tracing::warn!` for +/// cases where the transcript could not be read. +fn enrich_post_tool_use_with_model(parsed: &mut Value) { + let Some(obj) = parsed.as_object_mut() else { + return; + }; + + let Some(transcript_path_str) = obj.get("transcript_path").and_then(|v| v.as_str()) else { + tracing::warn!( + "PostToolUse enrichment: missing 'transcript_path' in payload; skipping model enrichment" + ); + return; + }; + let transcript_path = Path::new(transcript_path_str); + + let Some(tool_use_id) = obj.get("tool_use_id").and_then(|v| v.as_str()) else { + tracing::warn!( + "PostToolUse enrichment: missing 'tool_use_id' in payload; skipping model enrichment" + ); + return; + }; + + if let Some(model) = + claude_transcript::extract_claude_transcript_model(transcript_path, tool_use_id) + { + obj.insert("model".to_string(), Value::String(model)); + } else { + tracing::warn!( + "PostToolUse enrichment: could not extract model from transcript at '{}' for tool_use_id '{}'; writing artifact without model field", + transcript_path.display(), + tool_use_id + ); + } +} + +fn parse_claude_capture_payload(stdin_payload: &str) -> Result { + serde_json::from_str(stdin_payload) + .context("Invalid Claude capture payload from STDIN: expected valid JSON.") +} + fn parse_diff_trace_payload(stdin_payload: &str) -> Result { let parsed: Value = serde_json::from_str(stdin_payload) .context("Invalid diff-trace payload from STDIN: expected valid JSON.")?; @@ -296,7 +487,7 @@ fn persist_diff_trace_payload( repository_root: &Path, payload: &DiffTracePayload, ) -> Result { - let trace_directory = repository_root.join("context").join("tmp"); + let trace_directory = RepoPaths::new(repository_root).context_tmp_dir(); let serialized = format!( "{}\n", serde_json::to_string_pretty(payload) @@ -354,6 +545,22 @@ fn persist_serialized_trace_payload( trace_name: &str, serialized: &str, artifact_description: &str, +) -> Result { + persist_serialized_trace_payload_at( + trace_directory, + trace_name, + serialized, + artifact_description, + Utc::now(), + ) +} + +fn persist_serialized_trace_payload_at( + trace_directory: &Path, + trace_name: &str, + serialized: &str, + artifact_description: &str, + timestamp: DateTime, ) -> Result { fs::create_dir_all(trace_directory).with_context(|| { format!( @@ -362,12 +569,31 @@ fn persist_serialized_trace_payload( ) })?; - let timestamp = Utc::now(); + persist_trace_payload_with_retries( + trace_directory, + trace_name, + serialized, + artifact_description, + timestamp, + persist_trace_payload_to_file, + ) +} +fn persist_trace_payload_with_retries

( + trace_directory: &Path, + trace_name: &str, + serialized: &str, + artifact_description: &str, + timestamp: DateTime, + mut persist_file: P, +) -> Result +where + P: FnMut(&Path, &str) -> io::Result<()>, +{ for attempt in 0..MAX_TRACE_FILE_CREATE_ATTEMPTS { let file_path = trace_directory.join(build_trace_file_name(trace_name, timestamp, attempt)); - match persist_trace_payload_to_file(&file_path, serialized) { + match persist_file(&file_path, serialized) { Ok(()) => return Ok(file_path), Err(error) if error.kind() == ErrorKind::AlreadyExists => {} Err(error) => { @@ -757,6 +983,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", HookSubcommand::DiffTrace => "diff-trace runtime invocation", + HookSubcommand::ClaudeCapture { .. } => "claude-capture runtime invocation", } } @@ -766,7 +993,7 @@ fn persist_hook_trace( input: &Value, outcome: &Result, ) -> Result<()> { - let trace_directory = repository_root.join("context").join("tmp"); + let trace_directory = RepoPaths::new(repository_root).context_tmp_dir(); let body = match outcome { Ok(output) => json!({ "input": input, @@ -799,6 +1026,7 @@ fn hook_trace_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::PostCommit { .. } => "post-commit", HookSubcommand::PostRewrite { .. } => "post-rewrite", HookSubcommand::DiffTrace => "diff-trace", + HookSubcommand::ClaudeCapture { .. } => "claude-capture", } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 3b6df8d0..2eb8769c 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -344,16 +344,20 @@ fn convert_setup_command( fn convert_hooks_subcommand( subcommand: cli_schema::HooksSubcommand, ) -> Result { + let subcommand = convert_hooks_subcommand_request(subcommand)?; + + Ok(Box::new(services::hooks::command::HooksCommand { + subcommand, + })) +} + +fn convert_hooks_subcommand_request( + subcommand: cli_schema::HooksSubcommand, +) -> Result { match subcommand { - cli_schema::HooksSubcommand::PreCommit => { - Ok(Box::new(services::hooks::command::HooksCommand { - subcommand: services::hooks::HookSubcommand::PreCommit, - })) - } + cli_schema::HooksSubcommand::PreCommit => Ok(services::hooks::HookSubcommand::PreCommit), cli_schema::HooksSubcommand::CommitMsg { message_file } => { - Ok(Box::new(services::hooks::command::HooksCommand { - subcommand: services::hooks::HookSubcommand::CommitMsg { message_file }, - })) + Ok(services::hooks::HookSubcommand::CommitMsg { message_file }) } cli_schema::HooksSubcommand::PostCommit { vcs, remote_url } => { let vcs_type = parse_optional_hook_vcs_type(vcs.as_deref()) @@ -361,22 +365,20 @@ fn convert_hooks_subcommand( let remote_url = parse_optional_hook_remote_url(remote_url).map_err(ClassifiedError::validation)?; - Ok(Box::new(services::hooks::command::HooksCommand { - subcommand: services::hooks::HookSubcommand::PostCommit { - vcs_type, - remote_url: Some(remote_url), - }, - })) + Ok(services::hooks::HookSubcommand::PostCommit { + vcs_type, + remote_url: Some(remote_url), + }) } cli_schema::HooksSubcommand::PostRewrite { rewrite_method } => { - Ok(Box::new(services::hooks::command::HooksCommand { - subcommand: services::hooks::HookSubcommand::PostRewrite { rewrite_method }, - })) + Ok(services::hooks::HookSubcommand::PostRewrite { rewrite_method }) } - cli_schema::HooksSubcommand::DiffTrace => { - Ok(Box::new(services::hooks::command::HooksCommand { - subcommand: services::hooks::HookSubcommand::DiffTrace, - })) + cli_schema::HooksSubcommand::DiffTrace => Ok(services::hooks::HookSubcommand::DiffTrace), + cli_schema::HooksSubcommand::ClaudeCapture { event_name } => { + let event = services::hooks::ClaudeCaptureEvent::parse(&event_name) + .map_err(|error| ClassifiedError::validation(error.to_string()))?; + + Ok(services::hooks::HookSubcommand::ClaudeCapture { event }) } } } diff --git a/config/.claude/settings.json b/config/.claude/settings.json new file mode 100644 index 00000000..52ac4f0f --- /dev/null +++ b/config/.claude/settings.json @@ -0,0 +1,65 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "SessionStart" + ] + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "UserPromptSubmit" + ] + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "PostToolUse" + ] + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "Stop" + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 8de1452e..851e3b9a 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -47,6 +47,9 @@ output { text = document.rendered } } + ["config/.claude/settings.json"] { + text = claude.settings.rendered + } for (slug, document in opencode.skills) { ["config/.opencode/skills/\(slug)/SKILL.md"] { diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index b857f44c..ef02eff0 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -50,6 +50,77 @@ compatibility: \(metadata.skillCompatibility) } } +settings = new common.RenderedTextFile { + slug = "settings" + rendered = """ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "SessionStart" + ] + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "UserPromptSubmit" + ] + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "PostToolUse" + ] + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sce", + "args": [ + "hooks", + "claude-capture", + "Stop" + ] + } + ] + } + ] + } +} +""" +} + agents { for (unitSlug, unit in shared.agents) { [unitSlug] = new common.RenderedTargetDocument { diff --git a/context/architecture.md b/context/architecture.md index c7641aaa..c9d8b3ba 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -38,7 +38,7 @@ Current target renderer helper modules: - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) - `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,pkl-parity,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, generated-output parity, and JS validation inventory) -The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, and generated OpenCode package manifests). +The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). Renderer modules apply target-specific metadata/frontmatter rules while reusing canonical content bodies: @@ -49,7 +49,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - Target-specific metadata tables, including skill frontmatter descriptions, are isolated in `config/pkl/renderers/opencode-metadata.pkl`, `config/pkl/renderers/opencode-automated-metadata.pkl`, and `config/pkl/renderers/claude-metadata.pkl`. - Metadata key coverage is enforced by `config/pkl/renderers/metadata-coverage-check.pkl`, which resolves all required lookup keys for both targets and fails evaluation on missing entries. - Both renderers expose per-class rendered document objects (`agents`, `commands`, `skills`) consumed by `config/pkl/generate.pkl`. -- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode/Claude agents, commands, skills, shared bash-policy runtime and preset assets under `lib/`, the OpenCode plugin entrypoints under `plugins/` (currently `sce-bash-policy.ts` and `sce-agent-trace.ts`), generated OpenCode `package.json` and `opencode.json` manifests for manual and automated profiles, and the generated `sce/config.json` schema artifact at `config/schema/sce-config.schema.json`. +- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode/Claude agents, commands, skills, Claude project settings, shared bash-policy runtime and preset assets under `lib/`, the OpenCode plugin entrypoints under `plugins/` (currently `sce-bash-policy.ts` and `sce-agent-trace.ts`), generated OpenCode `package.json` and `opencode.json` manifests for manual and automated profiles, and the generated `sce/config.json` schema artifact at `config/schema/sce-config.schema.json`. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. - `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, runs `pkl eval -m config/pkl/generate.pkl`, and fails when generated-owned paths drift. @@ -59,6 +59,7 @@ Generated authored classes: - command definitions - skill definitions - shared runtime library files +- Claude project settings - OpenCode plugin entrypoints - generated OpenCode package manifests @@ -97,7 +98,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format and file-sink inputs with precedence `env > config file > defaults` for non-flag observability keys, optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic truncate-or-append policy), stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, stderr-only primary emission with optional mirrored file writes, and redaction-safe emission through the shared security helper. Its `observability::traits` submodule exposes the current `Logger` and object-safe `Telemetry` trait boundaries plus `NoopLogger`; the concrete observability logger and telemetry runtime still own behavior and implement those traits without changing runtime behavior. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on logger and telemetry runtime behavior. - `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. -- `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database files (global config, auth tokens, auth DB, local DB, agent trace DB), and owns canonical repo-relative, embedded-asset, install/runtime, hook, and context-path accessors so non-test production path definitions have one shared owner. Current production consumers such as config discovery, doctor reporting, setup/install flows, database adapters, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. +- `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database files (global config, auth tokens, auth DB, local DB, agent trace DB), and owns canonical repo-relative, embedded-asset, install/runtime, hook, and context-path accessors including the Claude raw capture directory under `context/tmp/claude/`, so non-test production path definitions have one shared owner. Current production consumers such as config discovery, doctor reporting, setup/install flows, database adapters, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. - `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation plus policy-specific formatting, `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate resolution, default-discovered invalid-file degradation, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`, and private `render.rs` owns `sce config show` / `sce config validate` text and JSON output construction plus rendering-specific display-value helpers. The facade preserves existing `services::config` imports for startup/auth/hooks callers while delegating command execution to resolution plus rendering submodules. - `cli/src/services/output_format.rs` defines the canonical shared CLI output-format contract (`OutputFormat`) for supporting commands, with deterministic `text|json` parsing and command-scoped actionable invalid-value guidance. - `cli/src/services/config/types.rs` is the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `LogFileMode`, the observability env-key constants, and the shared bool parsing helpers used by both config resolution and observability bootstrap; `cli/src/services/config/mod.rs` re-exports those primitives through the facade. @@ -115,7 +116,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns `DoctorCommand` and its `RuntimeCommand` impl. Runtime doctor execution receives `AppContext`, requests the shared lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Report fact collection still preserves current environment/repository/hook/integration display data, while service-owned lifecycle providers now own config validation, local DB and Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` struct and its `RuntimeCommand` impl. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` struct and its `RuntimeCommand` impl. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); while `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name` and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb. Success requires both persistence paths to succeed. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name` and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb; and hidden/internal `claude-capture` validates supported Claude hook event names, reads raw JSON from STDIN, and writes pretty JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - No user-invocable `sce sync` command is wired in the current runtime; local DB and Agent Trace DB bootstrap flows through lifecycle providers aggregated by setup, and DB health/repair flows through the doctor surface. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index ce2f4d75..8a902fc0 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -53,7 +53,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable, but are hidden from those top-level help surfaces Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, and `diff-trace`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, and `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including `model_id`. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including `model_id`, and `claude-capture ` accepts `SessionStart|UserPromptSubmit|PostToolUse|Stop` raw JSON payloads from STDIN and writes pretty JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -91,7 +91,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, an empty default repo-scoped database inventory, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode integration presence inventory for `plugins`, `agents`, `commands`, and `skills` derived from the embedded OpenCode setup asset catalog, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, and `diff-trace`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); and `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and command-failing AgentTraceDb insertion. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text; and `claude-capture` validates supported Claude event names, reads raw JSON from STDIN, and persists pretty JSON under `context/tmp/claude/` without AgentTraceDb writes. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - No `cli/src/services/sync.rs` module exists in the current codebase; `sce sync` command wiring is deferred, while local DB initialization and health ownership are split between setup and doctor. - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/cli/default-path-catalog.md b/context/cli/default-path-catalog.md index ec5933c7..de0a3612 100644 --- a/context/cli/default-path-catalog.md +++ b/context/cli/default-path-catalog.md @@ -25,7 +25,7 @@ - `.opencode/`, `.opencode/opencode.json` - `.claude/` - `.git/`, `.git/hooks/`, `.git/COMMIT_EDITMSG` -- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/` +- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/`, `context/tmp/claude/` ### Embedded/install paths @@ -42,5 +42,6 @@ - `cli/src/services/doctor/inspect.rs` also resolves OpenCode manifest/plugin/runtime/preset locations through shared `RepoPaths` and `InstallTargetPaths` accessors instead of owning those paths locally. - `cli/src/services/setup/mod.rs` now resolves setup target directory names and required hook identifiers through `default_paths.rs` constants/accessors instead of owning those path literals locally. - `cli/src/services/default_paths.rs` includes a regression test that scans non-test Rust source under `cli/src/` and fails when new centralized production path literals appear outside the default-path service. +- `cli/src/services/hooks/mod.rs` resolves Claude raw hook capture artifacts through `RepoPaths::claude_capture_tmp_dir()` instead of owning the `context/tmp/claude/` path shape locally. See also: [cli-command-surface.md](./cli-command-surface.md), [../architecture.md](../architecture.md), [../context-map.md](../context-map.md) diff --git a/context/context-map.md b/context/context-map.md index db2a3ce2..ed9a16c5 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -49,7 +49,8 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, and always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation as nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, required `u64` `time` validation, dual persistence to AgentTraceDb, and collision-safe `context/tmp/-000000-diff-trace.json` artifacts) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and hidden/internal raw Claude JSON capture routing) +- `context/sce/claude-raw-hook-capture.md` (current hidden/internal `sce hooks claude-capture ` CLI intake for raw Claude hook JSON capture plus Pkl-generated `config/.claude/settings.json` registration for `SessionStart`, `UserPromptSubmit`, `PostToolUse` with `Write|Edit|MultiEdit|NotebookEdit`, and `Stop`; includes repo-local `context/tmp/claude/` path ownership, collision-safe artifact naming, invalid JSON no-write behavior, and explicit boundaries excluding Claude diff-trace derivation, AgentTraceDb writes, OpenCode plugin changes, and doctor validation) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus the implementation target for generated OpenCode enforcement, including config schema, argv-prefix matching, fixed preset catalog/messages, and precedence rules) - `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, and TypeScript source ownership; Claude bash-policy enforcement has been removed from generated outputs) diff --git a/context/glossary.md b/context/glossary.md index 7d9ef029..7de53254 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -85,7 +85,7 @@ - `CLI capability traits`: Broad dependency-injection seam in `cli/src/services/capabilities.rs` consumed by `AppContext`. `FsOps`/`StdFsOps` wrap filesystem operations and `GitOps`/`ProcessGitOps` wrap git process execution plus repository-root and hooks-directory resolution; current services do not consume them internally yet. - `FsOps`: Filesystem capability trait in `cli/src/services/capabilities.rs` with `read_file`, `write_file`, `metadata`, and `exists`, implemented in production by `StdFsOps`. - `GitOps`: Git capability trait in `cli/src/services/capabilities.rs` with `run_command`, `resolve_repository_root`, `resolve_hooks_directory`, and `is_available`, implemented in production by `ProcessGitOps`. -- `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. +- `SCE default path policy seam`: Canonical path resolver in `cli/src/services/default_paths.rs` that owns config/state/cache root resolution through an internal `roots` helper seam, named default paths, and an explicit inventory for the current default persisted artifacts (`global config`, `auth tokens`); named DB paths include `auth DB`, `local DB`, and `Agent Trace DB`. On Linux those defaults resolve to `$XDG_CONFIG_HOME/sce/config.json`, `$XDG_STATE_HOME/sce/auth/tokens.json`, `$XDG_STATE_HOME/sce/auth.db`, `$XDG_STATE_HOME/sce/local.db`, and `$XDG_STATE_HOME/sce/agent-trace.db` with platform-equivalent `dirs` fallbacks elsewhere. The same module is also the canonical owner for broader production CLI path definitions, including repo-local `context/tmp/claude/` via `RepoPaths::claude_capture_tmp_dir()`, and is protected by a regression test that fails when new non-test production path literals are introduced outside `default_paths.rs`. - `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config/resolver.rs` with precedence `flags > env > config file > defaults` for flag-backed keys (`log_level`, `timeout_ms`) plus shared app-runtime observability keys (`log_format`, `log_file`, `log_file_mode`) consumed by `cli/src/app.rs`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${config_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key, where `config_root` comes from the shared default path policy seam and resolves to `$XDG_CONFIG_HOME` / `dirs::config_dir()` semantics with platform fallback behavior rather than the old state/data-root default). Runtime startup config loading permits the canonical top-level `"$schema": "https://sce.crocoder.dev/config.json"` declaration anywhere those config files are parsed (parsing delegated to `schema.rs`). - `shared runtime/config primitives seam`: Canonical ownership in `cli/src/services/config/types.rs` for the CLI's shared observability/config enums (`LogLevel`, `LogFormat`, `LogFileMode`), request/response primitives (`ConfigSubcommand`, `ConfigRequest`, `ReportFormat`), source metadata types (`ValueSource`, `ConfigPathSource`, `LoadedConfigPath`, `ResolvedValue`, `ResolvedOptionalValue`), resolved runtime config types (`ResolvedAuthRuntimeConfig`, `ResolvedObservabilityRuntimeConfig`, `ResolvedHookRuntimeConfig`), the `NAME` constant, observability env-key constants, and shared bool parsing helpers; re-exported through `cli/src/services/config/mod.rs` via `pub use types::*` so downstream modules continue importing through `services::config` unchanged. - `config schema and file parsing seam`: Canonical ownership in `cli/src/services/config/schema.rs` for the CLI's JSON Schema embedding (`SCE_CONFIG_SCHEMA_JSON`), `OnceLock` validator (`CONFIG_SCHEMA_VALIDATOR`, `config_schema_validator()`), top-level allowed-key validation (`TOP_LEVEL_CONFIG_KEYS`, `validate_object_keys`), serde DTO definitions (`ParsedFileConfigDocument`, `ParsedPoliciesConfigDocument`, `ParsedBashPolicyConfigDocument`, `ParsedAttributionHooksConfigDocument`, `ParsedCustomBashPolicyEntryDocument`, `ParsedCustomBashPolicyMatchDocument`), file config value wrapper (`FileConfigValue`) and aggregate (`FileConfig`), type aliases (`ParsedBashPolicyConfig`, `ParsedFilePolicies`), and config-file load/parse helpers (`validate_config_file`, `parse_file_config`, `deserialize_typed_config`, `map_policies_config`, `map_attribution_hooks_config`, `map_bash_policy_config`); `validate_config_file` is re-exported `pub(crate)` through `mod.rs` for `lifecycle.rs` and `doctor` consumers. Policy parsing helpers (`parse_bash_policy_presets`, `parse_custom_bash_policies`) and `CustomBashPolicyEntry` are imported from `super::policy` rather than the parent module. @@ -101,7 +101,8 @@ - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. - `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, and `diff-trace` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), and `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and hidden/internal `claude-capture ` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion, and `claude-capture` persists supported raw Claude hook JSON payloads under `context/tmp/claude/` without AgentTraceDb writes. +- `Claude raw hook capture`: Hidden/internal `sce hooks claude-capture ` intake path in `cli/src/services/hooks/mod.rs` for raw Claude hook JSON payloads, plus Pkl-generated Claude project settings at `config/.claude/settings.json` that register exec-form capture handlers for `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop` (`PostToolUse` matches `Write|Edit|MultiEdit|NotebookEdit`). The capture runtime writes pretty JSON artifacts under repo-local `context/tmp/claude/` and intentionally does not derive Claude diff traces, write AgentTraceDb rows, modify OpenCode behavior, or add doctor validation for Claude settings. - `cloud sync gateway placeholder`: Abstraction in `cli/src/services/sync.rs` (`CloudSyncGateway`) that returns deferred cloud-sync checkpoints while `sync` remains non-production. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. - `plan/code overlap map`: Context artifact at `context/sce/plan-code-overlap-map.md` that classifies Shared Context Plan/Code, `/change-to-plan`, `/next-task`, `/commit`, and core skills into role-specific vs shared-reusable instruction blocks with explicit dedup targets. @@ -119,7 +120,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled and SCE is not disabled; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, and `diff-trace` is an active intake path (validates required STDIN payload fields including `model_id`, `tool_name`, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb). +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, `diff-trace` is an active intake path (validates required STDIN payload fields including `model_id`, `tool_name`, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb), and hidden `claude-capture` is raw JSON diagnostic capture only under `context/tmp/claude/`. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, help/output expose deterministic doctor mode, JSON includes stable problem taxonomy/fixability fields plus database records and fix-result records, the runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode integration presence for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`. Human text mode now uses the approved sectioned layout (`Environment`, `Configuration` (includes Agent Trace DB row), `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and presence-only integration parent/child rows where missing required files surface as `[MISS]` children and `[FAIL]` parent groups. Fix mode still reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. diff --git a/context/overview.md b/context/overview.md index 77b19d25..1089b9dd 100644 --- a/context/overview.md +++ b/context/overview.md @@ -45,10 +45,10 @@ Context sync now uses an important-change gate: cross-cutting/policy/architectur The `/change-to-plan` command body is also intentionally thin orchestration: it delegates clarification and plan-shape contracts to `sce-plan-authoring` (including one-task/one-atomic-commit task slicing) while keeping wrapper-level plan output and handoff obligations explicit. The generated OpenCode command doc now also emits `entry-skill: sce-plan-authoring` plus an ordered `skills` list. The targeted support commands (`handover`, `commit`, `validate`) keep their thin-wrapper behavior and now also emit machine-readable OpenCode command frontmatter describing their entry skill and ordered skill chain. `/commit` is now split by profile: manual generated commands remain proposal-only and allow split guidance when staged changes mix unrelated goals, while the automated OpenCode `/commit` command generates exactly one commit message and runs `git commit` against the staged diff. The shared `sce-atomic-commit` contract also requires commit bodies to cite affected plan slug(s) and updated task ID(s) when staged changes include `context/plans/*.md`, and to stop for clarification instead of inventing those references when the staged plan diff is ambiguous. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. -The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); and `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames. +The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and hidden/internal `claude-capture` captures supported raw Claude hook JSON payloads into `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime now matches the implemented T06 slice for `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, and bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support dual-mode operation — local mode via `turso::Builder::new_local()` when `SCE_SYNC_URL`+`SCE_SYNC_TOKEN` are absent, or sync (Turso Cloud) mode via `turso::sync::Builder::new_remote()` when both are set. It owns parent-directory creation, connection setup, tokio current-thread runtime bridging, synchronous `execute`/`query`/`query_map`, generic migration execution, sync operations (`push`/`pull`/`checkpoint`/`stats`) that are no-ops in local mode (sync is never triggered automatically from `execute()`), and shared DB lifecycle helpers for service-specific database wrappers. Auth DB persistence now has a thin encrypted wrapper in `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb` resolves `/sce/auth.db` and embeds ordered `auth_tokens` table/index migrations, with lifecycle registration wired through `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`; auth runtime token-storage is now wired through `token_storage.rs`, which persists tokens via the `auth_credentials` table instead of a JSON file. Agent Trace persistence now has its own `cli/src/services/agent_trace_db/mod.rs` wrapper, canonical `/sce/agent-trace.db` path, a split fresh-start baseline migration set (`001..007`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, and indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`) without `AUTOINCREMENT`, plus `agent_traces.agent_trace_id` as `NOT NULL UNIQUE`; it also provides typed parameterized insert helpers for diff traces, post-commit intersection rows, and built agent-trace rows, chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active `sce hooks diff-trace` writes for `diff_traces`, and active `sce hooks post-commit` writes for built `agent_traces` payloads. -The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, while `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` payload persistence with required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. +The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` payload persistence with required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames, and `claude-capture` is a raw Claude hook JSON capture path for `SessionStart|UserPromptSubmit|PostToolUse|Stop` payloads under `context/tmp/claude/`. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/claude-raw-hook-capture.md`. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`), and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--both --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. @@ -108,6 +108,7 @@ Lightweight post-task verification baseline (required after each completed task) - Use `context/sce/agent-trace-retry-queue-observability.md` for the current inactive retry-replay status and retained historical notes about the removed local-hook retry path. - Use `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` for the frozen T01 Local Hooks MVP production contract and deterministic gap matrix that maps current seam-level code truth to the remaining implementation stack (`T02`..`T10`). - Use `context/sce/agent-trace-hooks-command-routing.md` for the implemented T02 `sce hooks` command routing contract (subcommand parsing, deterministic invocation errors, and initial runtime entrypoint behavior). +- Use `context/sce/claude-raw-hook-capture.md` for the current hidden/internal Claude raw hook JSON capture intake, Pkl-generated Claude settings registration, and no-diff-trace/no-AgentTraceDb boundary. - Use `context/sce/setup-githooks-hook-asset-packaging.md` for the implemented `sce-setup-githooks-any-repo` T02 compile-time hook-template packaging contract and setup-service required-hook embedded accessor surface. - Use `context/sce/setup-githooks-install-flow.md` for the implemented `sce-setup-githooks-any-repo` T03 required-hook install orchestration contract (git-truth hooks-path resolution, per-hook installed/updated/skipped outcomes, and remove-and-replace behavior). - Use `context/sce/setup-githooks-cli-ux.md` for the implemented `sce-setup-githooks-any-repo` T04 setup command-surface contract (`--hooks`, optional `--repo`), compatibility validation rules, and deterministic hook setup messaging. diff --git a/context/patterns.md b/context/patterns.md index 16c1e157..fde5953d 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -134,7 +134,7 @@ - For cross-service CLI dependencies that will be injected through `AppContext`, prefer broad capability traits in `cli/src/services/capabilities.rs` over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `model_id`, `tool_name`, and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and command-failing AgentTraceDb insertion through the existing database adapter. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `model_id`, `tool_name`, and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text while preserving the artifact fallback; keep hidden `claude-capture` as raw JSON diagnostic capture only under `context/tmp/claude/`, with no Claude diff-trace derivation and no AgentTraceDb writes. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. - For local hook attribution flows, resolve the top-level enablement gate through the shared config precedence model (`SCE_ATTRIBUTION_HOOKS_ENABLED` over `policies.attribution_hooks.enabled`, default `false`) so commit-msg attribution stays disabled by default without adding hook-specific config parsing. - Do not assume post-commit persistence, retry replay, remap ingestion, or rewrite trace transformation are active in the current local-hook runtime; those paths are removed from the current baseline. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 556c9dfa..87fe4e9b 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -98,8 +98,8 @@ Lookup indexes created by the baseline migration set: - The hook path validates required STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` before persistence (`tool_name` non-empty; `tool_version` present and either `null` or non-empty string) and passes parsed `model_id`, `tool_name`, and nullable `tool_version` into `DiffTraceInsert`. - `time` is accepted as a `u64` Unix epoch millisecond input and must fit the signed `i64` `time_ms` column before any persistence starts. -- The hook writes the existing collision-safe `context/tmp/-000000-diff-trace.json` parsed-payload artifact and inserts the parsed payload fields through `AgentTraceDb::insert_diff_trace()`. -- Command success requires both artifact and database persistence to succeed. +- The hook writes the existing collision-safe `context/tmp/-000000-diff-trace.json` parsed-payload artifact, then attempts to insert the parsed payload fields through `AgentTraceDb::insert_diff_trace()`. +- Command success requires artifact persistence to succeed; AgentTraceDb open/insert failures are logged and reflected in the success text as failed DB persistence instead of discarding the artifact fallback. - Existing artifact files are not backfilled into the database. Post-commit intersection rows are written by the active `post-commit` hook flow, and the same flow now also inserts built Agent Trace payloads into `agent_traces` via `AgentTraceDb::insert_agent_trace()` (see [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). The persisted `trace_json` is the schema-validated `build_agent_trace(...)` output and includes top-level `metadata.sce.version` from the compiled `sce` CLI package version plus `content_hash` on every emitted range. Range `content_hash` values are computed from the touched-line kind/content of the post-commit hunk that produced the persisted range, not from DB IDs, paths, line positions, or runtime metadata. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 33322d16..78df42b7 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -12,6 +12,7 @@ - `sce hooks post-commit [--vcs ] --remote-url ` - `sce hooks post-rewrite ` - `sce hooks diff-trace` +- hidden/internal `sce hooks claude-capture ` for raw Claude JSON capture ## Parser and dispatch behavior @@ -20,6 +21,7 @@ - `post-commit` now enforces required parse-time validation for `--remote-url` in `cli/src/services/parse/command_runtime.rs`. - `--vcs` remains optional and, when provided, must be one of `git|jj|hg|svn`; unsupported values fail with a validation-classified error. - Missing or blank `--remote-url` fails with a validation-classified error before runtime dispatch. +- `claude-capture` accepts only `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`; unsupported event names fail with validation-classified guidance before runtime dispatch. - Invalid and ambiguous invocations return deterministic actionable errors pointing to `sce hooks --help`. ## Current runtime behavior @@ -56,7 +58,8 @@ - Current command-surface success output is: `post-commit hook processed intersection: commit=, intersection_files=`. - `post-rewrite` is a deterministic no-op entrypoint. - `diff-trace` reads STDIN JSON, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, validates required `tool_version` (must be present and either `null` or a non-empty string), validates required `u64` `time` (Unix epoch milliseconds), rejects `time` values that cannot fit the Agent Trace DB signed `time_ms` column, writes one parsed-payload artifact per invocation to `context/tmp/-000000-diff-trace.json` with atomic create-new retry semantics, and inserts the parsed payload fields into AgentTraceDb via `DiffTraceInsert` + `insert_diff_trace()` including `model_id`. -- `diff-trace` success requires both persistence paths to succeed; artifact write failures and AgentTraceDb open/insert failures are command-failing runtime errors logged through `sce.hooks.diff_trace.error`. +- `diff-trace` command success requires artifact persistence to succeed. AgentTraceDb open/insert failures are logged through `sce.hooks.diff_trace.agent_trace_db_write_failed` and reflected in the success text as failed DB persistence, while the parsed-payload artifact remains the durable fallback. +- Hidden/internal `claude-capture` reads STDIN JSON and writes one pretty JSON artifact to `context/tmp/claude/--.json` with atomic create-new retry semantics; invalid JSON fails before persistence, and this route does not write AgentTraceDb or derive diff traces. See [claude-raw-hook-capture.md](claude-raw-hook-capture.md). ## Explicit non-goals in the current baseline @@ -65,3 +68,4 @@ - No backfill/import of existing `context/tmp/*-diff-trace.json` artifacts into AgentTraceDb - No retry queue replay - No rewrite remap ingestion +- No Claude diff-trace derivation or AgentTraceDb writes from raw Claude capture diff --git a/context/sce/claude-raw-hook-capture.md b/context/sce/claude-raw-hook-capture.md new file mode 100644 index 00000000..470e124d --- /dev/null +++ b/context/sce/claude-raw-hook-capture.md @@ -0,0 +1,63 @@ +# Claude Raw Hook Capture + +## Current implemented slice + +- Hidden/internal CLI route: `sce hooks claude-capture `. +- Supported event names are exactly `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`. +- Unsupported event names are rejected during clap-to-runtime conversion with deterministic validation guidance. +- Runtime reads one JSON payload from STDIN, parses it as `serde_json::Value`, enriches `PostToolUse` artifacts with the model identity from the Claude transcript, pretty-prints the (possibly enriched) JSON, and writes one artifact under the active repository's `context/tmp/claude/` directory. +- Invalid JSON fails before the persistence seam, so no malformed capture artifact is written. +- Pkl-generated Claude project settings register capture hooks in `config/.claude/settings.json` for `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`. +- The generated `PostToolUse` hook group matches `Write|Edit|MultiEdit|NotebookEdit`. +- Each generated handler uses Claude Code command-hook exec form with `command: "sce"` and args `hooks`, `claude-capture`, and the event name. + +## Path and artifact contract + +- Repo-local path ownership lives in `cli/src/services/default_paths.rs` as `RepoPaths::claude_capture_tmp_dir()`. +- The path shape is `/context/tmp/claude/`. +- Capture artifacts use the shared hook trace filename pattern: + - `--.json` + - timestamp format: `YYYY-MM-DDTHH-MM-SS-mmmZ` + - attempt is zero-padded to six digits +- Artifact writes use atomic create-new semantics and retry on filename collision up to the shared trace-attempt limit. + +## Runtime boundaries + +- Claude capture is raw-payload diagnostic storage only. +- Generated settings registration only invokes the raw capture CLI route. +- It does not derive Claude diff traces. +- It does not write to AgentTraceDb. +- It does not modify OpenCode agent-trace behavior or `sce hooks diff-trace` behavior. +- Doctor integration validation for Claude settings remains outside the MVP boundary. + +## Generated settings ownership + +- `config/pkl/renderers/claude-content.pkl` owns the rendered Claude settings document. +- `config/pkl/generate.pkl` emits that document to `config/.claude/settings.json`. +- The settings file is a generated-owned project-shareable Claude settings artifact and is included in normal generated-output parity checks. + +## PostToolUse model enrichment + +- `build_claude_capture_artifact` in `cli/src/services/hooks/mod.rs` enriches `PostToolUse` artifacts with the model identity before serialization. +- Enrichment is gated to `PostToolUse` only; `SessionStart`, `UserPromptSubmit`, and `Stop` remain unchanged. +- The enrichment reads `transcript_path` and `tool_use_id` from the STDIN JSON payload, calls `extract_claude_transcript_model`, and injects `"model": ""` as a top-level key in the captured artifact. +- If `transcript_path` or `tool_use_id` are missing, if the transcript is inaccessible, or if no matching assistant message is found, the artifact is written without a `"model"` field and a warning is logged. +- Existing non-model fields in the payload are preserved. + +## Transcript model extraction helper + +- Defined in `cli/src/services/hooks/claude_transcript.rs` as `extract_claude_transcript_model`. +- Reads a Claude JSONL transcript from an absolute path, scans assistant messages for a `tool_use` content block matching a given `tool_use_id`, and returns the `model` field from that message. +- Returns `None` gracefully for missing/unreadable files, malformed JSONL lines, unmatched IDs, or missing/non-string model fields. +- Wired in T01 of the `claude-capture-enrich-model` plan; activated by the PostToolUse enrichment in T02. +- `tempfile` added as `[dev-dependencies]` for inline transcript fixtures in unit tests. + +## Test contract + +- Parser coverage lives in `cli/src/cli_schema.rs`. +- Runtime conversion coverage lives in `cli/src/services/parse/command_runtime.rs`. +- Capture validation, pretty JSON serialization, invalid JSON no-write behavior, and collision retry coverage live in `cli/src/services/hooks/mod.rs`. +- Unit tests use pure/injected persistence seams rather than real filesystem fixtures so they stay compatible with the Nix sandbox test policy. +- Generated settings parity is covered by `nix run .#pkl-check-generated`; embedded asset compile/test coverage is covered by the CLI test derivation. + +See also: [agent-trace-hooks-command-routing.md](./agent-trace-hooks-command-routing.md), [../cli/default-path-catalog.md](../cli/default-path-catalog.md), [../context-map.md](../context-map.md) From 88a17f6bc7c2aaab887d276835d79bd47947779b Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Fri, 5 Jun 2026 17:10:33 +0200 Subject: [PATCH 02/18] agent-trace: Replace raw Claude capture with normalized session-model intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `sce hooks claude-capture` hidden CLI route, `ClaudeCaptureEvent` enum variant, `claude_transcript.rs` enrichment module, and `RepoPaths::claude_capture_tmp_dir()` — the raw JSON diagnostic path that wrote Claude hook payloads under `context/tmp/claude/` without DB persistence or cross-editor reuse. Add `sce hooks session-model` STDIN intake for normalized model attribution upsert into the new AgentTraceDb `session_models` table (migration 008). `diff-trace` now accepts optional `model_id` and resolves it from `session_models` when absent, enabling cross-editor reuse: OpenCode sends `model_id` directly, Claude relies on DB resolution. Generate a Claude TypeScript event-adapter runtime at `.claude/plugins/sce-agent-trace.ts` that emits normalized `session-model` and `diff-trace` payloads to the shared Rust intake. Update the generated-config pipeline (Pkl renderers, OpenCode plugin registration, settings) and build fileset to include the new agent-trace-plugin source tree. Update `context/` docs to reflect the removed raw capture route, the new session-model command routing, and the cross-editor shared boundary at `sce hooks diff-trace`. Co-authored-by: SCE --- .claude/plugins/sce-agent-trace.ts | 836 ++++++++++++++++++ .claude/settings.json | 20 +- .../agent-trace/008_create_session_models.sql | 11 + cli/src/cli_schema.rs | 10 +- cli/src/services/agent_trace_db/mod.rs | 113 +++ cli/src/services/default_paths.rs | 5 - cli/src/services/hooks/claude_transcript.rs | 85 -- cli/src/services/hooks/mod.rs | 370 ++++---- cli/src/services/parse/command_runtime.rs | 7 +- config/.claude/plugins/sce-agent-trace.ts | 836 ++++++++++++++++++ config/.claude/settings.json | 20 +- config/.opencode/plugins/sce-agent-trace.ts | 2 +- .../.opencode/plugins/sce-agent-trace.ts | 2 +- .../claude-sce-agent-trace-plugin.ts | 836 ++++++++++++++++++ .../opencode-sce-agent-trace-plugin.ts | 2 +- config/pkl/generate.pkl | 4 + config/pkl/renderers/claude-content.pkl | 20 +- context/architecture.md | 10 +- context/cli/cli-command-surface.md | 6 +- context/cli/default-path-catalog.md | 4 +- context/context-map.md | 10 +- context/glossary.md | 13 +- context/overview.md | 10 +- context/patterns.md | 6 +- context/sce/agent-trace-db.md | 26 +- .../sce/agent-trace-hooks-command-routing.md | 17 +- context/sce/claude-raw-hook-capture.md | 72 +- .../generated-opencode-plugin-registration.md | 2 + .../opencode-agent-trace-plugin-runtime.md | 7 + context/sce/shared-turso-db.md | 2 +- flake.nix | 2 + 31 files changed, 2958 insertions(+), 408 deletions(-) create mode 100644 .claude/plugins/sce-agent-trace.ts create mode 100644 cli/migrations/agent-trace/008_create_session_models.sql delete mode 100644 cli/src/services/hooks/claude_transcript.rs create mode 100644 config/.claude/plugins/sce-agent-trace.ts create mode 100644 config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts diff --git a/.claude/plugins/sce-agent-trace.ts b/.claude/plugins/sce-agent-trace.ts new file mode 100644 index 00000000..b7a3ac75 --- /dev/null +++ b/.claude/plugins/sce-agent-trace.ts @@ -0,0 +1,836 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "Stop", +] as const; + +export type ClaudeAgentTraceEventName = + (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; + +export type ClaudeDiffTracePayload = { + sessionID: string; + diff: string; + time: number; + model_id?: string; + tool_name: "claude"; + tool_version: string | null; +}; + +export type ClaudeDiffTraceSkipReason = + | "unsupported_event" + | "event_without_diff_trace" + | "invalid_payload" + | "event_name_mismatch" + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload" + | "missing_session_id"; + +export type ClaudeDiffTraceDerivationResult = + | { + status: "derived"; + payload: ClaudeDiffTracePayload; + } + | { + status: "skipped"; + reason: ClaudeDiffTraceSkipReason; + }; + +export type ClaudeHookPayloadParseResult = + | { + status: "ok"; + payload: unknown; + } + | { + status: "error"; + message: string; + }; + +export type DeriveClaudeDiffTraceInput = { + eventName: string; + payload: unknown; + now?: () => number; + toolVersion?: string | null; +}; + +type JsonObject = Record; + +type DiffBuildResult = + | { + status: "built"; + diff: string; + } + | { + status: "skipped"; + reason: + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload"; + }; + +const CLAUDE_MODEL_ID_PREFIX = "claude/"; + +export function isClaudeAgentTraceEventName( + value: string, +): value is ClaudeAgentTraceEventName { + return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( + value as ClaudeAgentTraceEventName, + ); +} + +export function parseClaudeHookPayloadJson( + input: string, +): ClaudeHookPayloadParseResult { + try { + return { + status: "ok", + payload: JSON.parse(input), + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function deriveClaudeDiffTracePayload( + input: DeriveClaudeDiffTraceInput, +): ClaudeDiffTraceDerivationResult { + if (!isClaudeAgentTraceEventName(input.eventName)) { + return skipped("unsupported_event"); + } + + if (input.eventName !== "PostToolUse") { + return skipped("event_without_diff_trace"); + } + + const payload = asObject(input.payload); + if (payload === undefined) { + return skipped("invalid_payload"); + } + + const payloadEventName = stringField(payload, "hook_event_name"); + if (payloadEventName !== undefined && payloadEventName !== input.eventName) { + return skipped("event_name_mismatch"); + } + + const diffResult = buildClaudePostToolUseDiff(payload); + if (diffResult.status === "skipped") { + return skipped(diffResult.reason); + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + if (sessionId === undefined) { + return skipped("missing_session_id"); + } + + return { + status: "derived", + payload: { + sessionID: sessionId, + diff: diffResult.diff, + time: currentTimeMs(input.now), + tool_name: "claude", + tool_version: extractClaudeToolVersion(input.toolVersion, payload), + }, + }; +} + +export function normalizeClaudeModelId(model: string): string | undefined { + const normalized = model.trim(); + if (normalized.length === 0) { + return undefined; + } + + if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { + return normalized; + } + + return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; +} + +function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { + const toolName = stringField(payload, "tool_name"); + if (toolName === "Write") { + return buildWriteCreateDiff(payload); + } + + if (toolName === "Edit") { + return buildEditStructuredPatchDiff(payload); + } + + return skipped("unsupported_tool"); +} + +function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_write_payload"); + } + + const originalFile = valueField( + toolResponse, + "originalFile", + "original_file", + ); + if (originalFile !== null) { + return skipped("unsupported_write_payload"); + } + + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + stringField(toolResponse, "file_path", "filePath"), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const content = stringValueField(toolInput, "content", "newFile", "new_file"); + if (content === undefined) { + return skipped("missing_file_content"); + } + + return { + status: "built", + diff: renderWriteCreateDiff(filePath, content), + }; +} + +function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_edit_payload"); + } + + const structuredPatch = valueField( + toolResponse, + "structuredPatch", + "structured_patch", + ); + if (structuredPatch === undefined || structuredPatch === null) { + return skipped("unsupported_edit_payload"); + } + + const patchObject = asObject(structuredPatch); + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + (patchObject === undefined + ? undefined + : stringField(patchObject, "file_path", "filePath", "path")), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const hunkValues = structuredPatchHunks(structuredPatch); + const renderedHunks = hunkValues + .map(renderStructuredPatchHunk) + .filter((hunk): hunk is string => hunk !== undefined); + + if (renderedHunks.length === 0) { + return skipped("unsupported_edit_payload"); + } + + return { + status: "built", + diff: renderEditStructuredPatchDiff(filePath, renderedHunks), + }; +} + +function renderWriteCreateDiff(filePath: string, content: string): string { + const contentLines = splitFileContent(content); + const diffLines = [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + ]; + + if (contentLines.length > 0) { + diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); + for (const line of contentLines) { + diffLines.push(`+${line}`); + } + } + + return `${diffLines.join("\n")}\n`; +} + +function renderEditStructuredPatchDiff( + filePath: string, + renderedHunks: string[], +): string { + return `${[ + `Index: ${filePath}`, + "===================================================================", + `--- a/${filePath}`, + `+++ b/${filePath}`, + ...renderedHunks, + ].join("\n")}\n`; +} + +function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { + const hunk = asObject(hunkValue); + if (hunk === undefined) { + return undefined; + } + + const lines = arrayField(hunk, "lines", "body", "changes") + ?.map(renderStructuredPatchLine) + .filter((line): line is string => line !== undefined); + if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { + return undefined; + } + + const oldStart = numericField( + hunk, + "oldStart", + "old_start", + "oldLine", + "old_line", + ); + const newStart = numericField( + hunk, + "newStart", + "new_start", + "newLine", + "new_line", + ); + if (oldStart === undefined || newStart === undefined) { + return undefined; + } + + const oldCount = + numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? + countOldHunkLines(lines); + const newCount = + numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? + countNewHunkLines(lines); + + return [ + `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, + ...lines, + ].join("\n"); +} + +function renderStructuredPatchLine(lineValue: unknown): string | undefined { + if (typeof lineValue === "string") { + if ( + lineValue.startsWith("+") || + lineValue.startsWith("-") || + lineValue.startsWith(" ") || + lineValue.startsWith("\\") + ) { + return lineValue; + } + + return ` ${lineValue}`; + } + + const line = asObject(lineValue); + if (line === undefined) { + return undefined; + } + + const content = stringValueField(line, "content", "text", "value"); + if (content === undefined) { + return undefined; + } + + const kind = stringField(line, "kind", "type", "operation", "change"); + if ( + kind === "context" || + kind === "unchanged" || + kind === "equal" || + kind === " " + ) { + return ` ${content}`; + } + + if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { + return `+${content}`; + } + + if ( + kind === "removed" || + kind === "remove" || + kind === "delete" || + kind === "-" + ) { + return `-${content}`; + } + + return undefined; +} + +function structuredPatchHunks(structuredPatch: unknown): unknown[] { + if (Array.isArray(structuredPatch)) { + return structuredPatch; + } + + const patchObject = asObject(structuredPatch); + if (patchObject === undefined) { + return []; + } + + const hunks = arrayField(patchObject, "hunks", "changes"); + if (hunks !== undefined) { + return hunks; + } + + if (arrayField(patchObject, "lines", "body") !== undefined) { + return [patchObject]; + } + + return []; +} + +function splitFileContent(content: string): string[] { + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (normalizedContent.length === 0) { + return []; + } + + if (normalizedContent.endsWith("\n")) { + return normalizedContent.slice(0, -1).split("\n"); + } + + return normalizedContent.split("\n"); +} + +function extractDirectPayloadModel(payload: JsonObject): string | undefined { + const directModel = stringField(payload, "model", "model_id", "modelId"); + if (directModel !== undefined) { + return directModel; + } + + const modelObject = asObject(payload.model); + if (modelObject === undefined) { + return undefined; + } + + return stringField(modelObject, "id", "model", "name"); +} + +function extractClaudeToolVersion( + inputToolVersion: string | null | undefined, + payload: JsonObject, +): string | null { + for (const value of [ + inputToolVersion, + payload.tool_version, + payload.claude_version, + payload.version, + ]) { + const normalized = normalizeOptionalVersion(value); + if (normalized !== undefined) { + return normalized; + } + } + + return null; +} + +function normalizeOptionalVersion(value: unknown): string | null | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized.length === 0 ? null : normalized; +} + +function normalizePatchPath( + filePath: string | undefined, + cwd: string | undefined, +): string | undefined { + if (filePath === undefined) { + return undefined; + } + + let normalized = filePath.trim(); + if (normalized.length === 0) { + return undefined; + } + + if ( + cwd !== undefined && + path.isAbsolute(normalized) && + path.isAbsolute(cwd.trim()) + ) { + const relativePath = path.relative(cwd.trim(), normalized); + if ( + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ) { + normalized = relativePath; + } + } + + normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); + return normalized.length === 0 || normalized === "." ? undefined : normalized; +} + +function hasTouchedLine(lines: string[]): boolean { + return lines.some((line) => line.startsWith("+") || line.startsWith("-")); +} + +function countOldHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) + .length; +} + +function countNewHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) + .length; +} + +function currentTimeMs(now: (() => number) | undefined): number { + const value = now === undefined ? Date.now() : now(); + return Number.isFinite(value) ? Math.trunc(value) : Date.now(); +} + +function asObject(value: unknown): JsonObject | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function stringField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "string") { + continue; + } + + const normalized = value.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +function stringValueField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value === "string") { + return value; + } + } + + return undefined; +} + +function numericField( + object: JsonObject, + ...keys: string[] +): number | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "number") { + continue; + } + + if (Number.isInteger(value) && value >= 0) { + return value; + } + } + + return undefined; +} + +function arrayField( + object: JsonObject, + ...keys: string[] +): unknown[] | undefined { + for (const key of keys) { + const value = object[key]; + if (Array.isArray(value)) { + return value; + } + } + + return undefined; +} + +function valueField(object: JsonObject, ...keys: string[]): unknown { + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + return undefined; +} + +function skipped( + reason: T, +): { + status: "skipped"; + reason: T; +} { + return { + status: "skipped", + reason, + }; +} + +// ─── Runtime: child-process spawn infrastructure ─────────────────────── + +/** + * Injectable spawn function signature used by the Claude hook runtime. + * Takes a command, arguments, stdin input, and optional cwd; resolves with + * the exit code and signal (null for normal exits). + */ +export type SpawnFn = ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, +) => Promise<{ code: number | null; signal: string | null }>; + +/** + * Real spawn implementation using `child_process.spawn`. + */ +export function createSpawnFn(): SpawnFn { + return ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, + ): Promise<{ code: number | null; signal: string | null }> => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: ["pipe", "ignore", "inherit"], + }); + + child.on("error", reject); + child.on("close", (code: number | null, signal: string | null) => { + resolve({ code, signal }); + }); + child.stdin.end(input); + }); + }; +} + +/** + * Read the entire contents of STDIN as a string. + * Returns an empty string when STDIN is a TTY (no piped data). + */ +export function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + + const chunks: Buffer[] = []; + + process.stdin.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + process.stdin.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + + process.stdin.on("error", reject); + process.stdin.resume(); + }); +} + +// ─── Runtime: Claude hook event orchestration ────────────────────────── + +/** + * Context passed to the Claude hook runtime for external dependencies. + */ +export type ClaudeHookRuntimeContext = { + /** Spawn function (real or mock) used for child-process forwarding. */ + spawn: SpawnFn; + /** Optional working directory forwarded to spawned processes. */ + cwd?: string; + /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ + now?: () => number; +}; + +/** + * Run the Claude hook runtime for a single hook event. + * + * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a + * normalized session-model payload to `sce hooks session-model` (best-effort). + * - `PostToolUse`: Derives a diff-trace payload and forwards it to + * `sce hooks diff-trace` (best-effort). Model attribution is resolved by + * Rust from `session_models`; TypeScript does not look up the model. + * - Other events: No-op (no raw capture forwarding). + * + * All forwarding errors are caught and logged to stderr without failing the + * Claude hook. + * + * @param eventName - Validated Claude hook event name + * @param rawJson - Raw JSON payload read from STDIN + * @param context - Injectable dependencies + */ +export async function runClaudeHookRuntime( + eventName: string, + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + if (eventName === "SessionStart") { + await handleSessionStart(rawJson, context); + return; + } + + // For PostToolUse, attempt best-effort diff-trace forwarding + if (eventName !== "PostToolUse") { + return; + } + + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const derivation = deriveClaudeDiffTracePayload({ + eventName, + payload: parseResult.payload, + now: context.now, + }); + + if (derivation.status !== "derived") { + return; + } + + await context.spawn( + "sce", + ["hooks", "diff-trace"], + `${JSON.stringify(derivation.payload)}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function handleSessionStart( + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const payload = asObject(parseResult.payload); + if (payload === undefined) { + return; + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + const modelId = extractDirectPayloadModel(payload); + if (sessionId === undefined || modelId === undefined) { + return; + } + + await context.spawn( + "sce", + ["hooks", "session-model"], + `${JSON.stringify({ + sessionID: sessionId, + time: currentTimeMs(context.now), + model_id: normalizeClaudeModelId(modelId), + tool_name: "claude", + tool_version: extractClaudeToolVersion(undefined, payload), + })}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. + * + * - Reads the event name from `process.argv[2]`. + * - Reads the hook JSON payload from STDIN. + * - Delegates to {@link runClaudeHookRuntime}. + * - Exits with code 1 on missing/invalid event name or stdin read failure. + * - Exits with code 0 otherwise (internal forwarding errors are best-effort + * and do not change the exit code). + */ +export async function main(): Promise { + const eventName = process.argv[2]; + + if (!eventName) { + console.error("Usage: sce-agent-trace.ts "); + process.exit(1); + } + + if (!isClaudeAgentTraceEventName(eventName)) { + console.error(`Unknown Claude hook event: ${eventName}`); + process.exit(1); + } + + let stdinContent: string; + try { + stdinContent = await readStdin(); + } catch (error) { + console.error( + `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + + try { + await runClaudeHookRuntime(eventName, stdinContent, { + spawn: createSpawnFn(), + }); + } catch (error) { + console.error( + `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +// Allow direct execution: `bun run .../sce-agent-trace.ts ` +if (import.meta.main) { + main(); +} diff --git a/.claude/settings.json b/.claude/settings.json index 52ac4f0f..98622696 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,10 +5,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "SessionStart" ] } @@ -20,10 +19,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "UserPromptSubmit" ] } @@ -36,10 +34,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "PostToolUse" ] } @@ -51,10 +48,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "Stop" ] } diff --git a/cli/migrations/agent-trace/008_create_session_models.sql b/cli/migrations/agent-trace/008_create_session_models.sql new file mode 100644 index 00000000..4f909c80 --- /dev/null +++ b/cli/migrations/agent-trace/008_create_session_models.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS session_models ( + id INTEGER PRIMARY KEY, + tool_name TEXT NOT NULL, + session_id TEXT NOT NULL, + model_id TEXT NOT NULL, + tool_version TEXT, + session_start_time_ms INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (tool_name, session_id) +); diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index b39b56cf..989a74a8 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -281,14 +281,10 @@ pub enum HooksSubcommand { DiffTrace, #[command( - name = "claude-capture", - about = "Capture raw Claude hook payload JSON from STDIN", - hide = true + name = "session-model", + about = "Ingest editor session model attribution (reads JSON payload from STDIN)" )] - ClaudeCapture { - #[arg(value_name = "event-name")] - event_name: String, - }, + SessionModel, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index f3bc5324..0ce181f3 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -28,6 +28,8 @@ const ADD_AGENT_TRACES_REMOTE_URL_MIGRATION: &str = const CREATE_AGENT_TRACES_REMOTE_URL_INDEX_MIGRATION: &str = include_str!( "../../../migrations/agent-trace/007_create_agent_traces_vcs_remote_url_index.sql" ); +const CREATE_SESSION_MODELS_MIGRATION: &str = + include_str!("../../../migrations/agent-trace/008_create_session_models.sql"); const AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ ("001_create_diff_traces", CREATE_DIFF_TRACES_MIGRATION), @@ -52,6 +54,7 @@ const AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ "007_create_agent_traces_remote_url_index", CREATE_AGENT_TRACES_REMOTE_URL_INDEX_MIGRATION, ), + ("008_create_session_models", CREATE_SESSION_MODELS_MIGRATION), ]; /// Parameterized SQL for inserting a captured diff trace payload. @@ -81,6 +84,31 @@ pub const INSERT_POST_COMMIT_PATCH_INTERSECTION_SQL: &str = pub const INSERT_AGENT_TRACE_SQL: &str = "INSERT INTO agent_traces (commit_id, commit_time_ms, trace_json, agent_trace_id, url, remote_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; +/// Parameterized SQL for upserting editor session model attribution. +pub const UPSERT_SESSION_MODEL_SQL: &str = "INSERT INTO session_models ( + tool_name, + session_id, + model_id, + tool_version, + session_start_time_ms +) VALUES (?1, ?2, ?3, ?4, ?5) +ON CONFLICT(tool_name, session_id) DO UPDATE SET + model_id = excluded.model_id, + tool_version = excluded.tool_version, + session_start_time_ms = excluded.session_start_time_ms, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; + +/// Parameterized SQL for retrieving editor session model attribution. +pub const SELECT_SESSION_MODEL_SQL: &str = "SELECT + tool_name, + session_id, + model_id, + tool_version, + session_start_time_ms +FROM session_models +WHERE tool_name = ?1 AND session_id = ?2 +LIMIT 1"; + /// Agent trace database configuration. pub struct AgentTraceDbSpec; @@ -112,6 +140,26 @@ pub struct DiffTraceInsert<'a> { pub tool_version: Option<&'a str>, } +/// Session model attribution payload to upsert into the agent trace database. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SessionModelUpsert<'a> { + pub tool_name: &'a str, + pub session_id: &'a str, + pub model_id: &'a str, + pub tool_version: Option<&'a str>, + pub session_start_time_ms: i64, +} + +/// Durable session model attribution row read from the agent trace database. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionModelAttribution { + pub tool_name: String, + pub session_id: String, + pub model_id: String, + pub tool_version: Option, + pub session_start_time_ms: i64, +} + /// Raw diff trace row read from the agent trace database. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DiffTracePatchRow { @@ -204,6 +252,20 @@ impl AgentTraceDb { insert_agent_trace_with(self, input) } + /// Upsert editor session model attribution into the `session_models` table. + pub fn upsert_session_model(&self, input: SessionModelUpsert<'_>) -> Result { + upsert_session_model_with(self, input) + } + + /// Retrieve editor session model attribution by `(tool_name, session_id)`. + pub fn session_model_by_tool_and_session( + &self, + tool_name: &str, + session_id: &str, + ) -> Result> { + session_model_by_tool_and_session_with(self, tool_name, session_id) + } + /// Query and parse recent diff trace patches within the inclusive time window. pub fn recent_diff_trace_patches( &self, @@ -260,6 +322,56 @@ fn insert_agent_trace_with(db: &TursoDb, input: AgentTraceInsert<' ) } +fn upsert_session_model_with( + db: &TursoDb, + input: SessionModelUpsert<'_>, +) -> Result { + db.execute( + UPSERT_SESSION_MODEL_SQL, + ( + input.tool_name, + input.session_id, + input.model_id, + input.tool_version, + input.session_start_time_ms, + ), + ) +} + +fn session_model_by_tool_and_session_with( + db: &TursoDb, + tool_name: &str, + session_id: &str, +) -> Result> { + let rows = db.query_map( + SELECT_SESSION_MODEL_SQL, + (tool_name, session_id), + session_model_attribution_from_turso, + )?; + + Ok(rows.into_iter().next()) +} + +fn session_model_attribution_from_turso(row: &turso::Row) -> Result { + Ok(SessionModelAttribution { + tool_name: row + .get(0) + .context("failed to read session_models.tool_name")?, + session_id: row + .get(1) + .context("failed to read session_models.session_id")?, + model_id: row + .get(2) + .context("failed to read session_models.model_id")?, + tool_version: row + .get(3) + .context("failed to read session_models.tool_version")?, + session_start_time_ms: row + .get(4) + .context("failed to read session_models.session_start_time_ms")?, + }) +} + fn recent_diff_trace_patches_with( db: &TursoDb, cutoff_time_ms: i64, @@ -567,6 +679,7 @@ mod tests { "005_create_agent_traces_agent_trace_id_index", "006_add_agent_traces_remote_url", "007_create_agent_traces_remote_url_index", + "008_create_session_models", ] ); diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index 2a6e456c..e7ac7d0f 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -375,7 +375,6 @@ pub(crate) mod context_dir { pub const DECISIONS: &str = "decisions"; pub const HANDOVERS: &str = "handovers"; pub const TMP: &str = "tmp"; - pub const CLAUDE: &str = "claude"; } #[allow(dead_code)] @@ -466,10 +465,6 @@ impl RepoPaths { self.context_dir().join(context_dir::TMP) } - pub(crate) fn claude_capture_tmp_dir(&self) -> PathBuf { - self.context_tmp_dir().join(context_dir::CLAUDE) - } - pub(crate) fn context_overview_file(&self) -> PathBuf { self.context_dir().join(context_file::OVERVIEW) } diff --git a/cli/src/services/hooks/claude_transcript.rs b/cli/src/services/hooks/claude_transcript.rs deleted file mode 100644 index 13f2620a..00000000 --- a/cli/src/services/hooks/claude_transcript.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::Path; - -use serde_json::Value; - -/// Extract the model identity from a Claude JSONL transcript by matching an -/// assistant message whose `tool_use` content block has the given `tool_use_id`. -/// -/// Returns `None` when: -/// - the transcript file is missing or unreadable -/// - any JSONL line is malformed -/// - no assistant message with a matching `tool_use` content block is found -/// - the matching assistant message has no `model` field or it is not a string -/// -/// Extracted from the capture flow in T02 to enrich `PostToolUse` capture artifacts. -pub fn extract_claude_transcript_model( - transcript_path: &Path, - tool_use_id: &str, -) -> Option { - let file = File::open(transcript_path).ok()?; - let reader = BufReader::new(file); - - for line in reader.lines() { - let line = line.ok()?; - if line.trim().is_empty() { - continue; - } - - let parsed: Value = serde_json::from_str(&line).ok()?; - let obj = parsed.as_object()?; - - // Determine whether this line represents an assistant message. - // The actual Claude transcript wraps the message inside a "message" envelope: - // {"type":"assistant","message":{"role":"assistant","model":"...","content":[...]}} - // Fall back to top-level "role" for simpler/legacy JSONL formats. - let msg = if let Some(msg_obj) = obj.get("message").and_then(|m| m.as_object()) { - let is_assistant = obj - .get("type") - .and_then(|t| t.as_str()) - .is_some_and(|t| t == "assistant") - || msg_obj - .get("role") - .and_then(|r| r.as_str()) - .is_some_and(|r| r == "assistant"); - if !is_assistant { - continue; - } - msg_obj - } else { - // Flat format: {"role":"assistant","model":"...","content":[...]} - match obj.get("role").and_then(|r| r.as_str()) { - Some("assistant") => {} - _ => continue, - } - obj - }; - - // Scan content blocks for a matching tool_use id. - let Some(content) = msg.get("content").and_then(|c| c.as_array()) else { - continue; - }; - let has_match = content.iter().any(|block| { - block - .as_object() - .and_then(|b| b.get("type")) - .and_then(|t| t.as_str()) - .is_some_and(|t| t == "tool_use") - && block - .as_object() - .and_then(|b| b.get("id")) - .and_then(|id| id.as_str()) - .is_some_and(|id| id == tool_use_id) - }); - - if !has_match { - continue; - } - - // Return the model field from the matching assistant message. - return msg.get("model").and_then(|m| m.as_str()).map(String::from); - } - - None -} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 215a3b31..772c2517 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -9,7 +9,6 @@ use anyhow::{anyhow, bail, Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; use serde_json::{json, to_string as serialize_to_json, Value}; -use tracing; use crate::services::agent_trace::{ build_agent_trace, validate_agent_trace_value, AgentTrace, AgentTraceMetadataInput, @@ -17,7 +16,7 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::{ AgentTraceDb, AgentTraceInsert, DiffTraceInsert, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, + RecentDiffTracePatches, SessionModelUpsert, }; use crate::services::observability::traits::Logger; use crate::services::patch::{ @@ -26,7 +25,6 @@ use crate::services::patch::{ }; use crate::services::{config, default_paths::RepoPaths}; -pub mod claude_transcript; pub mod command; pub mod lifecycle; @@ -35,46 +33,6 @@ pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE Result { - let normalized = event_name.trim(); - - match normalized { - "SessionStart" => Ok(Self::SessionStart), - "UserPromptSubmit" => Ok(Self::UserPromptSubmit), - "PostToolUse" => Ok(Self::PostToolUse), - "Stop" => Ok(Self::Stop), - _ => bail!(unsupported_claude_capture_event_error(normalized)), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::SessionStart => "SessionStart", - Self::UserPromptSubmit => "UserPromptSubmit", - Self::PostToolUse => "PostToolUse", - Self::Stop => "Stop", - } - } -} - -fn unsupported_claude_capture_event_error(event_name: &str) -> String { - format!( - "Unsupported Claude capture event '{event_name}'. Supported events: {SUPPORTED_CLAUDE_CAPTURE_EVENTS_TEXT}. Try: rerun with one of the supported Claude hook event names." - ) -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum HookSubcommand { PreCommit, @@ -89,28 +47,28 @@ pub enum HookSubcommand { rewrite_method: String, }, DiffTrace, - ClaudeCapture { - event: ClaudeCaptureEvent, - }, + SessionModel, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct DiffTracePayload { +struct SessionModelPayload { #[serde(rename = "sessionID")] session_id: String, - diff: String, time: u64, model_id: String, tool_name: String, tool_version: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] -struct TraceArtifactPayload { - trace_directory: PathBuf, - trace_name: String, - serialized: String, - artifact_description: &'static str, +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct DiffTracePayload { + #[serde(rename = "sessionID")] + session_id: String, + diff: String, + time: u64, + model_id: String, + tool_name: String, + tool_version: Option, } /// Required `sce hooks diff-trace` STDIN payload shape: @@ -152,9 +110,7 @@ fn run_hooks_subcommand_in_repo( run_post_rewrite_subcommand_with_trace(repository_root, subcommand, rewrite_method) } HookSubcommand::DiffTrace => run_diff_trace_subcommand(repository_root, logger), - HookSubcommand::ClaudeCapture { event } => { - run_claude_capture_subcommand(repository_root, *event, logger) - } + HookSubcommand::SessionModel => run_session_model_subcommand(repository_root, logger), } } @@ -178,6 +134,28 @@ fn run_diff_trace_subcommand_from_payload( logger: Option<&dyn Logger>, ) -> Result { let payload = parse_diff_trace_payload(stdin_payload)?; + let resolve_model = |tool_name: &str, session_id: &str| -> Result> { + let db = + AgentTraceDb::new().context("Failed to open Agent Trace DB for model resolution.")?; + let attribution = db + .session_model_by_tool_and_session(tool_name, session_id) + .context("Failed to query session model attribution from Agent Trace DB.")?; + Ok(attribution.map(|a| a.model_id)) + }; + + run_diff_trace_subcommand_from_payload_with(repository_root, payload, logger, resolve_model) +} + +fn run_diff_trace_subcommand_from_payload_with( + repository_root: &Path, + payload: DiffTracePayload, + logger: Option<&dyn Logger>, + _resolve_model: R, +) -> Result +where + R: FnOnce(&str, &str) -> Result>, +{ + // model_id is required from the caller; no resolution needed. if let Err(error) = diff_trace_db_time_ms(payload.time) { if let Some(log) = logger { log.warn( @@ -214,142 +192,67 @@ fn run_diff_trace_subcommand_from_payload( } } -fn run_claude_capture_subcommand( +fn run_session_model_subcommand( repository_root: &Path, - event: ClaudeCaptureEvent, logger: Option<&dyn Logger>, ) -> Result { let stdin_payload = read_hook_stdin()?; - let result = run_claude_capture_subcommand_from_payload(repository_root, event, &stdin_payload); + let result = run_session_model_subcommand_from_payload(repository_root, &stdin_payload, logger); if let Err(ref error) = result { if let Some(log) = logger { - log.error( - "sce.hooks.claude_capture.error", - &error.to_string(), - &[("event", event.as_str())], - ); + log.error("sce.hooks.session_model.error", &error.to_string(), &[]); } } result } -fn run_claude_capture_subcommand_from_payload( - repository_root: &Path, - event: ClaudeCaptureEvent, +fn run_session_model_subcommand_from_payload( + _repository_root: &Path, stdin_payload: &str, + logger: Option<&dyn Logger>, ) -> Result { - persist_claude_capture_payload(repository_root, event, stdin_payload)?; + let payload = parse_session_model_payload(stdin_payload)?; - Ok(format!( - "claude-capture hook intake persisted {} payload to context/tmp/claude.", - event.as_str() - )) -} - -fn persist_claude_capture_payload( - repository_root: &Path, - event: ClaudeCaptureEvent, - stdin_payload: &str, -) -> Result { - persist_claude_capture_payload_with(repository_root, event, stdin_payload, |artifact| { - persist_serialized_trace_payload( - &artifact.trace_directory, - &artifact.trace_name, - &artifact.serialized, - artifact.artifact_description, - ) - }) -} - -fn persist_claude_capture_payload_with

( - repository_root: &Path, - event: ClaudeCaptureEvent, - stdin_payload: &str, - persist_artifact: P, -) -> Result -where - P: FnOnce(TraceArtifactPayload) -> Result, -{ - let artifact = build_claude_capture_artifact(repository_root, event, stdin_payload)?; - - persist_artifact(artifact) -} - -fn build_claude_capture_artifact( - repository_root: &Path, - event: ClaudeCaptureEvent, - stdin_payload: &str, -) -> Result { - let mut parsed = parse_claude_capture_payload(stdin_payload)?; - - // Enrich PostToolUse artifacts with the model identity from the Claude - // transcript. Other event types (SessionStart, UserPromptSubmit, Stop) - // remain unchanged. - if event == ClaudeCaptureEvent::PostToolUse { - enrich_post_tool_use_with_model(&mut parsed); - } - - let serialized = format!( - "{}\n", - serde_json::to_string_pretty(&parsed) - .context("Failed to serialize Claude capture payload for persistence.")? - ); - - Ok(TraceArtifactPayload { - trace_directory: RepoPaths::new(repository_root).claude_capture_tmp_dir(), - trace_name: event.as_str().to_string(), - serialized, - artifact_description: "Claude capture payload", - }) -} - -/// Attempt to enrich a `PostToolUse` parsed payload with the model identity -/// from the Claude JSONL transcript. Reads `transcript_path` and `tool_use_id` -/// from the payload, calls the transcript extraction helper, and injects -/// the model as `"model"` in the root JSON object. -/// -/// Degrades gracefully: if `transcript_path` or `tool_use_id` are missing, -/// the transcript is inaccessible, or the model cannot be determined, the -/// payload is left unchanged. A warning is logged via `tracing::warn!` for -/// cases where the transcript could not be read. -fn enrich_post_tool_use_with_model(parsed: &mut Value) { - let Some(obj) = parsed.as_object_mut() else { - return; - }; - - let Some(transcript_path_str) = obj.get("transcript_path").and_then(|v| v.as_str()) else { - tracing::warn!( - "PostToolUse enrichment: missing 'transcript_path' in payload; skipping model enrichment" - ); - return; - }; - let transcript_path = Path::new(transcript_path_str); + // Convert the u64 time to i64 for DB storage. + let session_start_time_ms = i64::try_from(payload.time).map_err(|_| { + anyhow!(sm_err( + "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" + )) + })?; - let Some(tool_use_id) = obj.get("tool_use_id").and_then(|v| v.as_str()) else { - tracing::warn!( - "PostToolUse enrichment: missing 'tool_use_id' in payload; skipping model enrichment" - ); - return; + let upsert_payload = SessionModelUpsert { + tool_name: &payload.tool_name, + session_id: &payload.session_id, + model_id: &payload.model_id, + tool_version: payload.tool_version.as_deref(), + session_start_time_ms, }; - if let Some(model) = - claude_transcript::extract_claude_transcript_model(transcript_path, tool_use_id) - { - obj.insert("model".to_string(), Value::String(model)); - } else { - tracing::warn!( - "PostToolUse enrichment: could not extract model from transcript at '{}' for tool_use_id '{}'; writing artifact without model field", - transcript_path.display(), - tool_use_id - ); + let db = AgentTraceDb::new() + .context("Failed to open Agent Trace DB for session-model persistence.")?; + let result = db + .upsert_session_model(upsert_payload) + .context("Failed to persist session model attribution to Agent Trace DB."); + + match result { + Ok(_) => Ok(String::from( + "session-model hook intake persisted session model attribution to AgentTraceDb.", + )), + Err(error) => { + if let Some(log) = logger { + log.warn( + "sce.hooks.session_model.agent_trace_db_write_failed", + &error.to_string(), + &[], + ); + } + Ok(String::from( + "session-model hook intake completed; AgentTraceDb persistence failed.", + )) + } } } -fn parse_claude_capture_payload(stdin_payload: &str) -> Result { - serde_json::from_str(stdin_payload) - .context("Invalid Claude capture payload from STDIN: expected valid JSON.") -} - fn parse_diff_trace_payload(stdin_payload: &str) -> Result { let parsed: Value = serde_json::from_str(stdin_payload) .context("Invalid diff-trace payload from STDIN: expected valid JSON.")?; @@ -374,6 +277,123 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result { }) } +fn parse_session_model_payload(stdin_payload: &str) -> Result { + let parsed: Value = serde_json::from_str(stdin_payload) + .context("Invalid session-model payload from STDIN: expected valid JSON.")?; + let payload = parsed + .as_object() + .ok_or_else(|| anyhow!(sm_err("expected a JSON object")))?; + + let session_id = sm_non_empty(payload, "sessionID")?; + let time = sm_u64(payload, "time")?; + let model_id = sm_non_empty(payload, "model_id")?; + let tool_name = sm_non_empty(payload, "tool_name")?; + let tool_version = sm_nullable_or_non_empty(payload, "tool_version")?; + + Ok(SessionModelPayload { + session_id, + time, + model_id, + tool_name, + tool_version, + }) +} + +fn sm_err(detail: &str) -> String { + format!("Invalid session-model payload from STDIN: {detail}.") +} + +fn sm_non_empty(payload: &serde_json::Map, field: &str) -> Result { + let raw = payload + .get(field) + .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; + let value = raw.as_str().ok_or_else(|| { + anyhow!(sm_err(&format!( + "field '{field}' must be a non-empty string" + ))) + })?; + if value.trim().is_empty() { + bail!(sm_err(&format!( + "field '{field}' must be a non-empty string" + ))); + } + Ok(value.to_string()) +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +fn sm_u64(payload: &serde_json::Map, field: &str) -> Result { + let raw = payload + .get(field) + .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; + + if let Some(value) = raw.as_u64() { + return Ok(value); + } + + if let Some(value) = raw.as_i64() { + if value < 0 { + bail!(sm_err(&format!( + "field '{field}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + return Ok(value as u64); + } + + if let Some(value) = raw.as_f64() { + if value.fract() != 0.0 { + bail!(sm_err(&format!( + "field '{field}' must be a u64 Unix epoch millisecond value, got a fractional number" + ))); + } + if value < 0.0 { + bail!(sm_err(&format!( + "field '{field}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + if value > u64::MAX as f64 { + bail!(sm_err(&format!( + "field '{field}' must be a u64 Unix epoch millisecond value" + ))); + } + return Ok(value as u64); + } + + bail!(sm_err(&format!( + "field '{field}' must be a u64 Unix epoch millisecond value" + ))) +} + +fn sm_nullable_or_non_empty( + payload: &serde_json::Map, + field: &str, +) -> Result> { + let raw = payload + .get(field) + .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(sm_err(&format!( + "field '{field}' must be null or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(sm_err(&format!( + "field '{field}' must be null or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + fn required_nullable_or_non_empty_string_field( payload: &serde_json::Map, field_name: &str, @@ -983,7 +1003,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", HookSubcommand::DiffTrace => "diff-trace runtime invocation", - HookSubcommand::ClaudeCapture { .. } => "claude-capture runtime invocation", + HookSubcommand::SessionModel => "session-model runtime invocation", } } @@ -1026,7 +1046,7 @@ fn hook_trace_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::PostCommit { .. } => "post-commit", HookSubcommand::PostRewrite { .. } => "post-rewrite", HookSubcommand::DiffTrace => "diff-trace", - HookSubcommand::ClaudeCapture { .. } => "claude-capture", + HookSubcommand::SessionModel => "session-model", } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 2eb8769c..3c50e5b6 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -374,11 +374,8 @@ fn convert_hooks_subcommand_request( Ok(services::hooks::HookSubcommand::PostRewrite { rewrite_method }) } cli_schema::HooksSubcommand::DiffTrace => Ok(services::hooks::HookSubcommand::DiffTrace), - cli_schema::HooksSubcommand::ClaudeCapture { event_name } => { - let event = services::hooks::ClaudeCaptureEvent::parse(&event_name) - .map_err(|error| ClassifiedError::validation(error.to_string()))?; - - Ok(services::hooks::HookSubcommand::ClaudeCapture { event }) + cli_schema::HooksSubcommand::SessionModel => { + Ok(services::hooks::HookSubcommand::SessionModel) } } } diff --git a/config/.claude/plugins/sce-agent-trace.ts b/config/.claude/plugins/sce-agent-trace.ts new file mode 100644 index 00000000..b7a3ac75 --- /dev/null +++ b/config/.claude/plugins/sce-agent-trace.ts @@ -0,0 +1,836 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "Stop", +] as const; + +export type ClaudeAgentTraceEventName = + (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; + +export type ClaudeDiffTracePayload = { + sessionID: string; + diff: string; + time: number; + model_id?: string; + tool_name: "claude"; + tool_version: string | null; +}; + +export type ClaudeDiffTraceSkipReason = + | "unsupported_event" + | "event_without_diff_trace" + | "invalid_payload" + | "event_name_mismatch" + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload" + | "missing_session_id"; + +export type ClaudeDiffTraceDerivationResult = + | { + status: "derived"; + payload: ClaudeDiffTracePayload; + } + | { + status: "skipped"; + reason: ClaudeDiffTraceSkipReason; + }; + +export type ClaudeHookPayloadParseResult = + | { + status: "ok"; + payload: unknown; + } + | { + status: "error"; + message: string; + }; + +export type DeriveClaudeDiffTraceInput = { + eventName: string; + payload: unknown; + now?: () => number; + toolVersion?: string | null; +}; + +type JsonObject = Record; + +type DiffBuildResult = + | { + status: "built"; + diff: string; + } + | { + status: "skipped"; + reason: + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload"; + }; + +const CLAUDE_MODEL_ID_PREFIX = "claude/"; + +export function isClaudeAgentTraceEventName( + value: string, +): value is ClaudeAgentTraceEventName { + return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( + value as ClaudeAgentTraceEventName, + ); +} + +export function parseClaudeHookPayloadJson( + input: string, +): ClaudeHookPayloadParseResult { + try { + return { + status: "ok", + payload: JSON.parse(input), + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function deriveClaudeDiffTracePayload( + input: DeriveClaudeDiffTraceInput, +): ClaudeDiffTraceDerivationResult { + if (!isClaudeAgentTraceEventName(input.eventName)) { + return skipped("unsupported_event"); + } + + if (input.eventName !== "PostToolUse") { + return skipped("event_without_diff_trace"); + } + + const payload = asObject(input.payload); + if (payload === undefined) { + return skipped("invalid_payload"); + } + + const payloadEventName = stringField(payload, "hook_event_name"); + if (payloadEventName !== undefined && payloadEventName !== input.eventName) { + return skipped("event_name_mismatch"); + } + + const diffResult = buildClaudePostToolUseDiff(payload); + if (diffResult.status === "skipped") { + return skipped(diffResult.reason); + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + if (sessionId === undefined) { + return skipped("missing_session_id"); + } + + return { + status: "derived", + payload: { + sessionID: sessionId, + diff: diffResult.diff, + time: currentTimeMs(input.now), + tool_name: "claude", + tool_version: extractClaudeToolVersion(input.toolVersion, payload), + }, + }; +} + +export function normalizeClaudeModelId(model: string): string | undefined { + const normalized = model.trim(); + if (normalized.length === 0) { + return undefined; + } + + if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { + return normalized; + } + + return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; +} + +function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { + const toolName = stringField(payload, "tool_name"); + if (toolName === "Write") { + return buildWriteCreateDiff(payload); + } + + if (toolName === "Edit") { + return buildEditStructuredPatchDiff(payload); + } + + return skipped("unsupported_tool"); +} + +function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_write_payload"); + } + + const originalFile = valueField( + toolResponse, + "originalFile", + "original_file", + ); + if (originalFile !== null) { + return skipped("unsupported_write_payload"); + } + + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + stringField(toolResponse, "file_path", "filePath"), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const content = stringValueField(toolInput, "content", "newFile", "new_file"); + if (content === undefined) { + return skipped("missing_file_content"); + } + + return { + status: "built", + diff: renderWriteCreateDiff(filePath, content), + }; +} + +function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_edit_payload"); + } + + const structuredPatch = valueField( + toolResponse, + "structuredPatch", + "structured_patch", + ); + if (structuredPatch === undefined || structuredPatch === null) { + return skipped("unsupported_edit_payload"); + } + + const patchObject = asObject(structuredPatch); + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + (patchObject === undefined + ? undefined + : stringField(patchObject, "file_path", "filePath", "path")), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const hunkValues = structuredPatchHunks(structuredPatch); + const renderedHunks = hunkValues + .map(renderStructuredPatchHunk) + .filter((hunk): hunk is string => hunk !== undefined); + + if (renderedHunks.length === 0) { + return skipped("unsupported_edit_payload"); + } + + return { + status: "built", + diff: renderEditStructuredPatchDiff(filePath, renderedHunks), + }; +} + +function renderWriteCreateDiff(filePath: string, content: string): string { + const contentLines = splitFileContent(content); + const diffLines = [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + ]; + + if (contentLines.length > 0) { + diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); + for (const line of contentLines) { + diffLines.push(`+${line}`); + } + } + + return `${diffLines.join("\n")}\n`; +} + +function renderEditStructuredPatchDiff( + filePath: string, + renderedHunks: string[], +): string { + return `${[ + `Index: ${filePath}`, + "===================================================================", + `--- a/${filePath}`, + `+++ b/${filePath}`, + ...renderedHunks, + ].join("\n")}\n`; +} + +function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { + const hunk = asObject(hunkValue); + if (hunk === undefined) { + return undefined; + } + + const lines = arrayField(hunk, "lines", "body", "changes") + ?.map(renderStructuredPatchLine) + .filter((line): line is string => line !== undefined); + if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { + return undefined; + } + + const oldStart = numericField( + hunk, + "oldStart", + "old_start", + "oldLine", + "old_line", + ); + const newStart = numericField( + hunk, + "newStart", + "new_start", + "newLine", + "new_line", + ); + if (oldStart === undefined || newStart === undefined) { + return undefined; + } + + const oldCount = + numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? + countOldHunkLines(lines); + const newCount = + numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? + countNewHunkLines(lines); + + return [ + `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, + ...lines, + ].join("\n"); +} + +function renderStructuredPatchLine(lineValue: unknown): string | undefined { + if (typeof lineValue === "string") { + if ( + lineValue.startsWith("+") || + lineValue.startsWith("-") || + lineValue.startsWith(" ") || + lineValue.startsWith("\\") + ) { + return lineValue; + } + + return ` ${lineValue}`; + } + + const line = asObject(lineValue); + if (line === undefined) { + return undefined; + } + + const content = stringValueField(line, "content", "text", "value"); + if (content === undefined) { + return undefined; + } + + const kind = stringField(line, "kind", "type", "operation", "change"); + if ( + kind === "context" || + kind === "unchanged" || + kind === "equal" || + kind === " " + ) { + return ` ${content}`; + } + + if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { + return `+${content}`; + } + + if ( + kind === "removed" || + kind === "remove" || + kind === "delete" || + kind === "-" + ) { + return `-${content}`; + } + + return undefined; +} + +function structuredPatchHunks(structuredPatch: unknown): unknown[] { + if (Array.isArray(structuredPatch)) { + return structuredPatch; + } + + const patchObject = asObject(structuredPatch); + if (patchObject === undefined) { + return []; + } + + const hunks = arrayField(patchObject, "hunks", "changes"); + if (hunks !== undefined) { + return hunks; + } + + if (arrayField(patchObject, "lines", "body") !== undefined) { + return [patchObject]; + } + + return []; +} + +function splitFileContent(content: string): string[] { + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (normalizedContent.length === 0) { + return []; + } + + if (normalizedContent.endsWith("\n")) { + return normalizedContent.slice(0, -1).split("\n"); + } + + return normalizedContent.split("\n"); +} + +function extractDirectPayloadModel(payload: JsonObject): string | undefined { + const directModel = stringField(payload, "model", "model_id", "modelId"); + if (directModel !== undefined) { + return directModel; + } + + const modelObject = asObject(payload.model); + if (modelObject === undefined) { + return undefined; + } + + return stringField(modelObject, "id", "model", "name"); +} + +function extractClaudeToolVersion( + inputToolVersion: string | null | undefined, + payload: JsonObject, +): string | null { + for (const value of [ + inputToolVersion, + payload.tool_version, + payload.claude_version, + payload.version, + ]) { + const normalized = normalizeOptionalVersion(value); + if (normalized !== undefined) { + return normalized; + } + } + + return null; +} + +function normalizeOptionalVersion(value: unknown): string | null | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized.length === 0 ? null : normalized; +} + +function normalizePatchPath( + filePath: string | undefined, + cwd: string | undefined, +): string | undefined { + if (filePath === undefined) { + return undefined; + } + + let normalized = filePath.trim(); + if (normalized.length === 0) { + return undefined; + } + + if ( + cwd !== undefined && + path.isAbsolute(normalized) && + path.isAbsolute(cwd.trim()) + ) { + const relativePath = path.relative(cwd.trim(), normalized); + if ( + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ) { + normalized = relativePath; + } + } + + normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); + return normalized.length === 0 || normalized === "." ? undefined : normalized; +} + +function hasTouchedLine(lines: string[]): boolean { + return lines.some((line) => line.startsWith("+") || line.startsWith("-")); +} + +function countOldHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) + .length; +} + +function countNewHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) + .length; +} + +function currentTimeMs(now: (() => number) | undefined): number { + const value = now === undefined ? Date.now() : now(); + return Number.isFinite(value) ? Math.trunc(value) : Date.now(); +} + +function asObject(value: unknown): JsonObject | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function stringField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "string") { + continue; + } + + const normalized = value.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +function stringValueField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value === "string") { + return value; + } + } + + return undefined; +} + +function numericField( + object: JsonObject, + ...keys: string[] +): number | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "number") { + continue; + } + + if (Number.isInteger(value) && value >= 0) { + return value; + } + } + + return undefined; +} + +function arrayField( + object: JsonObject, + ...keys: string[] +): unknown[] | undefined { + for (const key of keys) { + const value = object[key]; + if (Array.isArray(value)) { + return value; + } + } + + return undefined; +} + +function valueField(object: JsonObject, ...keys: string[]): unknown { + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + return undefined; +} + +function skipped( + reason: T, +): { + status: "skipped"; + reason: T; +} { + return { + status: "skipped", + reason, + }; +} + +// ─── Runtime: child-process spawn infrastructure ─────────────────────── + +/** + * Injectable spawn function signature used by the Claude hook runtime. + * Takes a command, arguments, stdin input, and optional cwd; resolves with + * the exit code and signal (null for normal exits). + */ +export type SpawnFn = ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, +) => Promise<{ code: number | null; signal: string | null }>; + +/** + * Real spawn implementation using `child_process.spawn`. + */ +export function createSpawnFn(): SpawnFn { + return ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, + ): Promise<{ code: number | null; signal: string | null }> => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: ["pipe", "ignore", "inherit"], + }); + + child.on("error", reject); + child.on("close", (code: number | null, signal: string | null) => { + resolve({ code, signal }); + }); + child.stdin.end(input); + }); + }; +} + +/** + * Read the entire contents of STDIN as a string. + * Returns an empty string when STDIN is a TTY (no piped data). + */ +export function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + + const chunks: Buffer[] = []; + + process.stdin.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + process.stdin.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + + process.stdin.on("error", reject); + process.stdin.resume(); + }); +} + +// ─── Runtime: Claude hook event orchestration ────────────────────────── + +/** + * Context passed to the Claude hook runtime for external dependencies. + */ +export type ClaudeHookRuntimeContext = { + /** Spawn function (real or mock) used for child-process forwarding. */ + spawn: SpawnFn; + /** Optional working directory forwarded to spawned processes. */ + cwd?: string; + /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ + now?: () => number; +}; + +/** + * Run the Claude hook runtime for a single hook event. + * + * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a + * normalized session-model payload to `sce hooks session-model` (best-effort). + * - `PostToolUse`: Derives a diff-trace payload and forwards it to + * `sce hooks diff-trace` (best-effort). Model attribution is resolved by + * Rust from `session_models`; TypeScript does not look up the model. + * - Other events: No-op (no raw capture forwarding). + * + * All forwarding errors are caught and logged to stderr without failing the + * Claude hook. + * + * @param eventName - Validated Claude hook event name + * @param rawJson - Raw JSON payload read from STDIN + * @param context - Injectable dependencies + */ +export async function runClaudeHookRuntime( + eventName: string, + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + if (eventName === "SessionStart") { + await handleSessionStart(rawJson, context); + return; + } + + // For PostToolUse, attempt best-effort diff-trace forwarding + if (eventName !== "PostToolUse") { + return; + } + + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const derivation = deriveClaudeDiffTracePayload({ + eventName, + payload: parseResult.payload, + now: context.now, + }); + + if (derivation.status !== "derived") { + return; + } + + await context.spawn( + "sce", + ["hooks", "diff-trace"], + `${JSON.stringify(derivation.payload)}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function handleSessionStart( + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const payload = asObject(parseResult.payload); + if (payload === undefined) { + return; + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + const modelId = extractDirectPayloadModel(payload); + if (sessionId === undefined || modelId === undefined) { + return; + } + + await context.spawn( + "sce", + ["hooks", "session-model"], + `${JSON.stringify({ + sessionID: sessionId, + time: currentTimeMs(context.now), + model_id: normalizeClaudeModelId(modelId), + tool_name: "claude", + tool_version: extractClaudeToolVersion(undefined, payload), + })}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. + * + * - Reads the event name from `process.argv[2]`. + * - Reads the hook JSON payload from STDIN. + * - Delegates to {@link runClaudeHookRuntime}. + * - Exits with code 1 on missing/invalid event name or stdin read failure. + * - Exits with code 0 otherwise (internal forwarding errors are best-effort + * and do not change the exit code). + */ +export async function main(): Promise { + const eventName = process.argv[2]; + + if (!eventName) { + console.error("Usage: sce-agent-trace.ts "); + process.exit(1); + } + + if (!isClaudeAgentTraceEventName(eventName)) { + console.error(`Unknown Claude hook event: ${eventName}`); + process.exit(1); + } + + let stdinContent: string; + try { + stdinContent = await readStdin(); + } catch (error) { + console.error( + `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + + try { + await runClaudeHookRuntime(eventName, stdinContent, { + spawn: createSpawnFn(), + }); + } catch (error) { + console.error( + `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +// Allow direct execution: `bun run .../sce-agent-trace.ts ` +if (import.meta.main) { + main(); +} diff --git a/config/.claude/settings.json b/config/.claude/settings.json index 52ac4f0f..98622696 100644 --- a/config/.claude/settings.json +++ b/config/.claude/settings.json @@ -5,10 +5,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "SessionStart" ] } @@ -20,10 +19,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "UserPromptSubmit" ] } @@ -36,10 +34,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "PostToolUse" ] } @@ -51,10 +48,9 @@ "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "Stop" ] } diff --git a/config/.opencode/plugins/sce-agent-trace.ts b/config/.opencode/plugins/sce-agent-trace.ts index 33e6ad7a..8546182d 100644 --- a/config/.opencode/plugins/sce-agent-trace.ts +++ b/config/.opencode/plugins/sce-agent-trace.ts @@ -1,5 +1,5 @@ -import type { Hooks, Plugin } from "@opencode-ai/plugin"; import { spawn } from "node:child_process"; +import type { Hooks, Plugin } from "@opencode-ai/plugin"; type OpenCodeEvent = Parameters>[0]["event"]; diff --git a/config/automated/.opencode/plugins/sce-agent-trace.ts b/config/automated/.opencode/plugins/sce-agent-trace.ts index 33e6ad7a..8546182d 100644 --- a/config/automated/.opencode/plugins/sce-agent-trace.ts +++ b/config/automated/.opencode/plugins/sce-agent-trace.ts @@ -1,5 +1,5 @@ -import type { Hooks, Plugin } from "@opencode-ai/plugin"; import { spawn } from "node:child_process"; +import type { Hooks, Plugin } from "@opencode-ai/plugin"; type OpenCodeEvent = Parameters>[0]["event"]; diff --git a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts new file mode 100644 index 00000000..b7a3ac75 --- /dev/null +++ b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts @@ -0,0 +1,836 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "Stop", +] as const; + +export type ClaudeAgentTraceEventName = + (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; + +export type ClaudeDiffTracePayload = { + sessionID: string; + diff: string; + time: number; + model_id?: string; + tool_name: "claude"; + tool_version: string | null; +}; + +export type ClaudeDiffTraceSkipReason = + | "unsupported_event" + | "event_without_diff_trace" + | "invalid_payload" + | "event_name_mismatch" + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload" + | "missing_session_id"; + +export type ClaudeDiffTraceDerivationResult = + | { + status: "derived"; + payload: ClaudeDiffTracePayload; + } + | { + status: "skipped"; + reason: ClaudeDiffTraceSkipReason; + }; + +export type ClaudeHookPayloadParseResult = + | { + status: "ok"; + payload: unknown; + } + | { + status: "error"; + message: string; + }; + +export type DeriveClaudeDiffTraceInput = { + eventName: string; + payload: unknown; + now?: () => number; + toolVersion?: string | null; +}; + +type JsonObject = Record; + +type DiffBuildResult = + | { + status: "built"; + diff: string; + } + | { + status: "skipped"; + reason: + | "unsupported_tool" + | "unsupported_write_payload" + | "missing_file_path" + | "missing_file_content" + | "unsupported_edit_payload"; + }; + +const CLAUDE_MODEL_ID_PREFIX = "claude/"; + +export function isClaudeAgentTraceEventName( + value: string, +): value is ClaudeAgentTraceEventName { + return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( + value as ClaudeAgentTraceEventName, + ); +} + +export function parseClaudeHookPayloadJson( + input: string, +): ClaudeHookPayloadParseResult { + try { + return { + status: "ok", + payload: JSON.parse(input), + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function deriveClaudeDiffTracePayload( + input: DeriveClaudeDiffTraceInput, +): ClaudeDiffTraceDerivationResult { + if (!isClaudeAgentTraceEventName(input.eventName)) { + return skipped("unsupported_event"); + } + + if (input.eventName !== "PostToolUse") { + return skipped("event_without_diff_trace"); + } + + const payload = asObject(input.payload); + if (payload === undefined) { + return skipped("invalid_payload"); + } + + const payloadEventName = stringField(payload, "hook_event_name"); + if (payloadEventName !== undefined && payloadEventName !== input.eventName) { + return skipped("event_name_mismatch"); + } + + const diffResult = buildClaudePostToolUseDiff(payload); + if (diffResult.status === "skipped") { + return skipped(diffResult.reason); + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + if (sessionId === undefined) { + return skipped("missing_session_id"); + } + + return { + status: "derived", + payload: { + sessionID: sessionId, + diff: diffResult.diff, + time: currentTimeMs(input.now), + tool_name: "claude", + tool_version: extractClaudeToolVersion(input.toolVersion, payload), + }, + }; +} + +export function normalizeClaudeModelId(model: string): string | undefined { + const normalized = model.trim(); + if (normalized.length === 0) { + return undefined; + } + + if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { + return normalized; + } + + return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; +} + +function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { + const toolName = stringField(payload, "tool_name"); + if (toolName === "Write") { + return buildWriteCreateDiff(payload); + } + + if (toolName === "Edit") { + return buildEditStructuredPatchDiff(payload); + } + + return skipped("unsupported_tool"); +} + +function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_write_payload"); + } + + const originalFile = valueField( + toolResponse, + "originalFile", + "original_file", + ); + if (originalFile !== null) { + return skipped("unsupported_write_payload"); + } + + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + stringField(toolResponse, "file_path", "filePath"), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const content = stringValueField(toolInput, "content", "newFile", "new_file"); + if (content === undefined) { + return skipped("missing_file_content"); + } + + return { + status: "built", + diff: renderWriteCreateDiff(filePath, content), + }; +} + +function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { + const toolInput = asObject(payload.tool_input); + const toolResponse = asObject(payload.tool_response); + if (toolInput === undefined || toolResponse === undefined) { + return skipped("unsupported_edit_payload"); + } + + const structuredPatch = valueField( + toolResponse, + "structuredPatch", + "structured_patch", + ); + if (structuredPatch === undefined || structuredPatch === null) { + return skipped("unsupported_edit_payload"); + } + + const patchObject = asObject(structuredPatch); + const filePath = normalizePatchPath( + stringField(toolInput, "file_path", "filePath") ?? + (patchObject === undefined + ? undefined + : stringField(patchObject, "file_path", "filePath", "path")), + stringField(payload, "cwd"), + ); + if (filePath === undefined) { + return skipped("missing_file_path"); + } + + const hunkValues = structuredPatchHunks(structuredPatch); + const renderedHunks = hunkValues + .map(renderStructuredPatchHunk) + .filter((hunk): hunk is string => hunk !== undefined); + + if (renderedHunks.length === 0) { + return skipped("unsupported_edit_payload"); + } + + return { + status: "built", + diff: renderEditStructuredPatchDiff(filePath, renderedHunks), + }; +} + +function renderWriteCreateDiff(filePath: string, content: string): string { + const contentLines = splitFileContent(content); + const diffLines = [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + ]; + + if (contentLines.length > 0) { + diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); + for (const line of contentLines) { + diffLines.push(`+${line}`); + } + } + + return `${diffLines.join("\n")}\n`; +} + +function renderEditStructuredPatchDiff( + filePath: string, + renderedHunks: string[], +): string { + return `${[ + `Index: ${filePath}`, + "===================================================================", + `--- a/${filePath}`, + `+++ b/${filePath}`, + ...renderedHunks, + ].join("\n")}\n`; +} + +function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { + const hunk = asObject(hunkValue); + if (hunk === undefined) { + return undefined; + } + + const lines = arrayField(hunk, "lines", "body", "changes") + ?.map(renderStructuredPatchLine) + .filter((line): line is string => line !== undefined); + if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { + return undefined; + } + + const oldStart = numericField( + hunk, + "oldStart", + "old_start", + "oldLine", + "old_line", + ); + const newStart = numericField( + hunk, + "newStart", + "new_start", + "newLine", + "new_line", + ); + if (oldStart === undefined || newStart === undefined) { + return undefined; + } + + const oldCount = + numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? + countOldHunkLines(lines); + const newCount = + numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? + countNewHunkLines(lines); + + return [ + `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, + ...lines, + ].join("\n"); +} + +function renderStructuredPatchLine(lineValue: unknown): string | undefined { + if (typeof lineValue === "string") { + if ( + lineValue.startsWith("+") || + lineValue.startsWith("-") || + lineValue.startsWith(" ") || + lineValue.startsWith("\\") + ) { + return lineValue; + } + + return ` ${lineValue}`; + } + + const line = asObject(lineValue); + if (line === undefined) { + return undefined; + } + + const content = stringValueField(line, "content", "text", "value"); + if (content === undefined) { + return undefined; + } + + const kind = stringField(line, "kind", "type", "operation", "change"); + if ( + kind === "context" || + kind === "unchanged" || + kind === "equal" || + kind === " " + ) { + return ` ${content}`; + } + + if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { + return `+${content}`; + } + + if ( + kind === "removed" || + kind === "remove" || + kind === "delete" || + kind === "-" + ) { + return `-${content}`; + } + + return undefined; +} + +function structuredPatchHunks(structuredPatch: unknown): unknown[] { + if (Array.isArray(structuredPatch)) { + return structuredPatch; + } + + const patchObject = asObject(structuredPatch); + if (patchObject === undefined) { + return []; + } + + const hunks = arrayField(patchObject, "hunks", "changes"); + if (hunks !== undefined) { + return hunks; + } + + if (arrayField(patchObject, "lines", "body") !== undefined) { + return [patchObject]; + } + + return []; +} + +function splitFileContent(content: string): string[] { + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (normalizedContent.length === 0) { + return []; + } + + if (normalizedContent.endsWith("\n")) { + return normalizedContent.slice(0, -1).split("\n"); + } + + return normalizedContent.split("\n"); +} + +function extractDirectPayloadModel(payload: JsonObject): string | undefined { + const directModel = stringField(payload, "model", "model_id", "modelId"); + if (directModel !== undefined) { + return directModel; + } + + const modelObject = asObject(payload.model); + if (modelObject === undefined) { + return undefined; + } + + return stringField(modelObject, "id", "model", "name"); +} + +function extractClaudeToolVersion( + inputToolVersion: string | null | undefined, + payload: JsonObject, +): string | null { + for (const value of [ + inputToolVersion, + payload.tool_version, + payload.claude_version, + payload.version, + ]) { + const normalized = normalizeOptionalVersion(value); + if (normalized !== undefined) { + return normalized; + } + } + + return null; +} + +function normalizeOptionalVersion(value: unknown): string | null | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized.length === 0 ? null : normalized; +} + +function normalizePatchPath( + filePath: string | undefined, + cwd: string | undefined, +): string | undefined { + if (filePath === undefined) { + return undefined; + } + + let normalized = filePath.trim(); + if (normalized.length === 0) { + return undefined; + } + + if ( + cwd !== undefined && + path.isAbsolute(normalized) && + path.isAbsolute(cwd.trim()) + ) { + const relativePath = path.relative(cwd.trim(), normalized); + if ( + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ) { + normalized = relativePath; + } + } + + normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); + return normalized.length === 0 || normalized === "." ? undefined : normalized; +} + +function hasTouchedLine(lines: string[]): boolean { + return lines.some((line) => line.startsWith("+") || line.startsWith("-")); +} + +function countOldHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) + .length; +} + +function countNewHunkLines(lines: string[]): number { + return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) + .length; +} + +function currentTimeMs(now: (() => number) | undefined): number { + const value = now === undefined ? Date.now() : now(); + return Number.isFinite(value) ? Math.trunc(value) : Date.now(); +} + +function asObject(value: unknown): JsonObject | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function stringField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "string") { + continue; + } + + const normalized = value.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +function stringValueField( + object: JsonObject, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value === "string") { + return value; + } + } + + return undefined; +} + +function numericField( + object: JsonObject, + ...keys: string[] +): number | undefined { + for (const key of keys) { + const value = object[key]; + if (typeof value !== "number") { + continue; + } + + if (Number.isInteger(value) && value >= 0) { + return value; + } + } + + return undefined; +} + +function arrayField( + object: JsonObject, + ...keys: string[] +): unknown[] | undefined { + for (const key of keys) { + const value = object[key]; + if (Array.isArray(value)) { + return value; + } + } + + return undefined; +} + +function valueField(object: JsonObject, ...keys: string[]): unknown { + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + return undefined; +} + +function skipped( + reason: T, +): { + status: "skipped"; + reason: T; +} { + return { + status: "skipped", + reason, + }; +} + +// ─── Runtime: child-process spawn infrastructure ─────────────────────── + +/** + * Injectable spawn function signature used by the Claude hook runtime. + * Takes a command, arguments, stdin input, and optional cwd; resolves with + * the exit code and signal (null for normal exits). + */ +export type SpawnFn = ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, +) => Promise<{ code: number | null; signal: string | null }>; + +/** + * Real spawn implementation using `child_process.spawn`. + */ +export function createSpawnFn(): SpawnFn { + return ( + command: string, + args: readonly string[], + input: string, + options?: { cwd?: string }, + ): Promise<{ code: number | null; signal: string | null }> => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: ["pipe", "ignore", "inherit"], + }); + + child.on("error", reject); + child.on("close", (code: number | null, signal: string | null) => { + resolve({ code, signal }); + }); + child.stdin.end(input); + }); + }; +} + +/** + * Read the entire contents of STDIN as a string. + * Returns an empty string when STDIN is a TTY (no piped data). + */ +export function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + + const chunks: Buffer[] = []; + + process.stdin.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + process.stdin.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + + process.stdin.on("error", reject); + process.stdin.resume(); + }); +} + +// ─── Runtime: Claude hook event orchestration ────────────────────────── + +/** + * Context passed to the Claude hook runtime for external dependencies. + */ +export type ClaudeHookRuntimeContext = { + /** Spawn function (real or mock) used for child-process forwarding. */ + spawn: SpawnFn; + /** Optional working directory forwarded to spawned processes. */ + cwd?: string; + /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ + now?: () => number; +}; + +/** + * Run the Claude hook runtime for a single hook event. + * + * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a + * normalized session-model payload to `sce hooks session-model` (best-effort). + * - `PostToolUse`: Derives a diff-trace payload and forwards it to + * `sce hooks diff-trace` (best-effort). Model attribution is resolved by + * Rust from `session_models`; TypeScript does not look up the model. + * - Other events: No-op (no raw capture forwarding). + * + * All forwarding errors are caught and logged to stderr without failing the + * Claude hook. + * + * @param eventName - Validated Claude hook event name + * @param rawJson - Raw JSON payload read from STDIN + * @param context - Injectable dependencies + */ +export async function runClaudeHookRuntime( + eventName: string, + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + if (eventName === "SessionStart") { + await handleSessionStart(rawJson, context); + return; + } + + // For PostToolUse, attempt best-effort diff-trace forwarding + if (eventName !== "PostToolUse") { + return; + } + + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const derivation = deriveClaudeDiffTracePayload({ + eventName, + payload: parseResult.payload, + now: context.now, + }); + + if (derivation.status !== "derived") { + return; + } + + await context.spawn( + "sce", + ["hooks", "diff-trace"], + `${JSON.stringify(derivation.payload)}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function handleSessionStart( + rawJson: string, + context: ClaudeHookRuntimeContext, +): Promise { + try { + const parseResult = parseClaudeHookPayloadJson(rawJson); + if (parseResult.status !== "ok") { + return; + } + + const payload = asObject(parseResult.payload); + if (payload === undefined) { + return; + } + + const sessionId = stringField(payload, "session_id", "sessionID"); + const modelId = extractDirectPayloadModel(payload); + if (sessionId === undefined || modelId === undefined) { + return; + } + + await context.spawn( + "sce", + ["hooks", "session-model"], + `${JSON.stringify({ + sessionID: sessionId, + time: currentTimeMs(context.now), + model_id: normalizeClaudeModelId(modelId), + tool_name: "claude", + tool_version: extractClaudeToolVersion(undefined, payload), + })}\n`, + { cwd: context.cwd }, + ); + } catch (error) { + console.error( + `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. + * + * - Reads the event name from `process.argv[2]`. + * - Reads the hook JSON payload from STDIN. + * - Delegates to {@link runClaudeHookRuntime}. + * - Exits with code 1 on missing/invalid event name or stdin read failure. + * - Exits with code 0 otherwise (internal forwarding errors are best-effort + * and do not change the exit code). + */ +export async function main(): Promise { + const eventName = process.argv[2]; + + if (!eventName) { + console.error("Usage: sce-agent-trace.ts "); + process.exit(1); + } + + if (!isClaudeAgentTraceEventName(eventName)) { + console.error(`Unknown Claude hook event: ${eventName}`); + process.exit(1); + } + + let stdinContent: string; + try { + stdinContent = await readStdin(); + } catch (error) { + console.error( + `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + + try { + await runClaudeHookRuntime(eventName, stdinContent, { + spawn: createSpawnFn(), + }); + } catch (error) { + console.error( + `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +// Allow direct execution: `bun run .../sce-agent-trace.ts ` +if (import.meta.main) { + main(); +} diff --git a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts index 33e6ad7a..8546182d 100644 --- a/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts +++ b/config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts @@ -1,5 +1,5 @@ -import type { Hooks, Plugin } from "@opencode-ai/plugin"; import { spawn } from "node:child_process"; +import type { Hooks, Plugin } from "@opencode-ai/plugin"; type OpenCodeEvent = Parameters>[0]["event"]; diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 851e3b9a..1d154d8f 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -9,6 +9,7 @@ local bashPolicyPresetCatalogSource = bash_policy_presets.output.text local bashPolicyRuntimeSource = read("../lib/bash-policy-plugin/bash-policy/runtime.ts").text local opencodeBashPolicyPluginSource = read("../lib/bash-policy-plugin/opencode-bash-policy-plugin.ts").text local opencodeAgentTracePluginSource = read("../lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts").text +local claudeAgentTracePluginSource = read("../lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts").text output { files { @@ -50,6 +51,9 @@ output { ["config/.claude/settings.json"] { text = claude.settings.rendered } + ["config/.claude/plugins/sce-agent-trace.ts"] { + text = claudeAgentTracePluginSource + } for (slug, document in opencode.skills) { ["config/.opencode/skills/\(slug)/SKILL.md"] { diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index ef02eff0..5e14bdc1 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -60,10 +60,9 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "SessionStart" ] } @@ -75,10 +74,9 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "UserPromptSubmit" ] } @@ -91,10 +89,9 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "PostToolUse" ] } @@ -106,10 +103,9 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "sce", + "command": "bun", "args": [ - "hooks", - "claude-capture", + ".claude/plugins/sce-agent-trace.ts", "Stop" ] } diff --git a/context/architecture.md b/context/architecture.md index c9d8b3ba..f121b33a 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -38,7 +38,7 @@ Current target renderer helper modules: - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) - `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,pkl-parity,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, generated-output parity, and JS validation inventory) -The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). +The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated Claude plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). Renderer modules apply target-specific metadata/frontmatter rules while reusing canonical content bodies: @@ -49,7 +49,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - Target-specific metadata tables, including skill frontmatter descriptions, are isolated in `config/pkl/renderers/opencode-metadata.pkl`, `config/pkl/renderers/opencode-automated-metadata.pkl`, and `config/pkl/renderers/claude-metadata.pkl`. - Metadata key coverage is enforced by `config/pkl/renderers/metadata-coverage-check.pkl`, which resolves all required lookup keys for both targets and fails evaluation on missing entries. - Both renderers expose per-class rendered document objects (`agents`, `commands`, `skills`) consumed by `config/pkl/generate.pkl`. -- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode/Claude agents, commands, skills, Claude project settings, shared bash-policy runtime and preset assets under `lib/`, the OpenCode plugin entrypoints under `plugins/` (currently `sce-bash-policy.ts` and `sce-agent-trace.ts`), generated OpenCode `package.json` and `opencode.json` manifests for manual and automated profiles, and the generated `sce/config.json` schema artifact at `config/schema/sce-config.schema.json`. +- `config/pkl/generate.pkl` emits deterministic `output.files` mappings for all authored generated targets: OpenCode/Claude agents, commands, skills, Claude project settings, the Claude agent-trace plugin entrypoint under `config/.claude/plugins/`, shared bash-policy runtime and preset assets under `lib/`, the OpenCode plugin entrypoints under `plugins/` (currently `sce-bash-policy.ts` and `sce-agent-trace.ts`), generated OpenCode `package.json` and `opencode.json` manifests for manual and automated profiles, and the generated `sce/config.json` schema artifact at `config/schema/sce-config.schema.json`. - Generated-file warning markers are not injected by the generator: Markdown outputs render deterministic frontmatter + body, and shared library outputs are emitted without a leading generated warning header. - `config/pkl/check-generated.sh` is intentionally dev-shell scoped (`nix develop -c ...`): it requires `IN_NIX_SHELL`, runs `pkl eval -m config/pkl/generate.pkl`, and fails when generated-owned paths drift. @@ -108,7 +108,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, and ordered embedded migrations, while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization, Turso connection setup, and tokio current-thread runtime bridging. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key via `encryption_key::get_or_create_encryption_key()` and enables Turso local encryption with strict `aegis256` cipher selection. Both public adapters delegate their synchronous `execute`/`query`/`query_map` wrappers and generic migration execution to the shared internal `TursoConnectionCore`, preserving one operation path and per-database `__sce_migrations` metadata. Existing DB files without migration metadata are upgraded by re-applying the current idempotent migration set and recording each migration ID, so setup/lifecycle initialization applies later migrations to already-created databases. The same module owns shared DB lifecycle helpers for path-health problem collection and DB parent-directory bootstrap. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`, which now persists tokens via the `auth_credentials` table instead of a JSON file. -- `cli/src/services/agent_trace_db/mod.rs` provides the Agent Trace DB spec and `AgentTraceDb` type alias over `TursoDb`. `AgentTraceDbSpec` resolves `/sce/agent-trace.db` through the shared default-path seam and embeds an ordered split fresh-start baseline migration set (`001_create_diff_traces`, `002_create_post_commit_patch_intersections`, `003_create_agent_traces`, `004_create_diff_traces_time_ms_id_index`, `005_create_agent_traces_agent_trace_id_index`, `006_add_agent_traces_remote_url`, `007_create_agent_traces_remote_url_index`) without `AUTOINCREMENT`; `agent_traces.agent_trace_id` is `NOT NULL UNIQUE`, `agent_traces.remote_url` is nullable, and indexes include `idx_agent_traces_agent_trace_id` plus `idx_agent_traces_remote_url`. The module adds `DiffTraceInsert<'_>`/`insert_diff_trace()` (including `model_id`, `tool_name`, and nullable `tool_version` writes), `PostCommitPatchIntersectionInsert<'_>`/`insert_post_commit_patch_intersection()`, and `AgentTraceInsert<'_>`/`insert_agent_trace()` for parameterized writes plus `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` for inclusive chronological `diff_traces` reads that parse valid raw patch text and return skipped malformed-row reports. `cli/src/services/agent_trace_db/lifecycle.rs` registers Agent Trace DB setup/doctor lifecycle behavior; runtime writes come from `sce hooks diff-trace` (`diff_traces`) and `sce hooks post-commit` (`post_commit_patch_intersections` + built `agent_traces`). +- `cli/src/services/agent_trace_db/mod.rs` provides the Agent Trace DB spec and `AgentTraceDb` type alias over `TursoDb`. `AgentTraceDbSpec` resolves `/sce/agent-trace.db` through the shared default-path seam and embeds an ordered split fresh-start baseline migration set (`001_create_diff_traces`, `002_create_post_commit_patch_intersections`, `003_create_agent_traces`, `004_create_diff_traces_time_ms_id_index`, `005_create_agent_traces_agent_trace_id_index`, `006_add_agent_traces_remote_url`, `007_create_agent_traces_remote_url_index`, `008_create_session_models`) without `AUTOINCREMENT`; `agent_traces.agent_trace_id` is `NOT NULL UNIQUE`, `agent_traces.remote_url` is nullable, and `session_models` is keyed by `(tool_name, session_id)` for durable editor session model attribution. The module adds `DiffTraceInsert<'_>`/`insert_diff_trace()` (including `model_id`, `tool_name`, and nullable `tool_version` writes), `PostCommitPatchIntersectionInsert<'_>`/`insert_post_commit_patch_intersection()`, `AgentTraceInsert<'_>`/`insert_agent_trace()`, and `SessionModelUpsert<'_>`/`upsert_session_model()` plus lookup helpers for parameterized writes/reads, with `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` providing inclusive chronological `diff_traces` reads that parse valid raw patch text and return skipped malformed-row reports. `cli/src/services/agent_trace_db/lifecycle.rs` registers Agent Trace DB setup/doctor lifecycle behavior; current runtime writes come from `sce hooks diff-trace` (`diff_traces`), `sce hooks session-model` (`session_models` via normalized STDIN intake), and `sce hooks post-commit` (`post_commit_patch_intersections` + built `agent_traces`). - `cli/src/test_support.rs` provides a shared test-only temp-directory helper (`TestTempDir`) used by service tests that need filesystem fixtures. - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups) generated by `cli/build.rs` from the ephemeral crate-local `cli/assets/generated/config/{opencode,claude}/**` mirror plus `cli/assets/hooks/**`, and focused internal support seams for install-flow vs prompt-flow logic; `cli/src/services/setup/command.rs` owns `SetupCommand` and its `RuntimeCommand` impl. Its install engine/orchestrator stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). The setup command derives a repo-root-scoped context from the runtime `AppContext` before aggregating `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers receive the runtime logger, telemetry, and capability objects instead of a setup-local replacement context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. @@ -116,13 +116,13 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns `DoctorCommand` and its `RuntimeCommand` impl. Runtime doctor execution receives `AppContext`, requests the shared lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Report fact collection still preserves current environment/repository/hook/integration display data, while service-owned lifecycle providers now own config validation, local DB and Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` struct and its `RuntimeCommand` impl. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` struct and its `RuntimeCommand` impl. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name` and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb; and hidden/internal `claude-capture` validates supported Claude hook event names, reads raw JSON from STDIN, and writes pretty JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name` and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb; `session-model` performs STDIN JSON intake, validates required non-empty `sessionID`/`model_id`/`tool_name`, required `u64` `time` (Unix epoch milliseconds), and required nullable/non-empty `tool_version`, then upserts the parsed payload into AgentTraceDb `session_models` without writing raw hook artifacts. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - No user-invocable `sce sync` command is wired in the current runtime; local DB and Agent Trace DB bootstrap flows through lifecycle providers aggregated by setup, and DB health/repair flows through the doctor surface. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, version, completion, help, patch, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` trait, `RuntimeCommandHandle` type alias, `CommandRegistry` struct, and `build_default_registry()` function for the command dispatch registry. Service-owned command modules now own the migrated runtime command structs and `RuntimeCommand` impls for help/help-text, version, completion, auth, config, setup, doctor, and hooks. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. -- `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs the bash-policy runtime test from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. +- `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs Bun-discovered tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. - `flake.nix` exposes release install/run surfaces as `packages.sce` (`packages.default = packages.sce`) plus `apps.sce` and `apps.default`, all targeting `${packages.sce}/bin/sce`; this keeps repo-local and remote flake run/install flows (`nix run .`, `nix run github:crocoder-dev/shared-context-engineering`, `nix profile install github:crocoder-dev/shared-context-engineering`) aligned to the same packaged CLI output. - `biome.json` at the repository root is the canonical Biome configuration for the current JS tooling slice and deliberately scopes coverage to `npm/**` plus the shared `config/lib/**` plugin package root while excluding package-local `node_modules/**`; `flake.nix` exposes Biome through the default dev shell rather than through package-local installs. - `cli/Cargo.toml` now keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`), git (`cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`), and local checkout (`cargo install --path cli --locked`) guidance. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 8a902fc0..f2e5ef44 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -53,7 +53,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable, but are hidden from those top-level help surfaces Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including `model_id`, and `claude-capture ` accepts `SessionStart|UserPromptSubmit|PostToolUse|Stop` raw JSON payloads from STDIN and writes pretty JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including `model_id`, and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -91,7 +91,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, an empty default repo-scoped database inventory, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode integration presence inventory for `plugins`, `agents`, `commands`, and `skills` derived from the embedded OpenCode setup asset catalog, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text; and `claude-capture` validates supported Claude event names, reads raw JSON from STDIN, and persists pretty JSON under `context/tmp/claude/` without AgentTraceDb writes. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text; and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - No `cli/src/services/sync.rs` module exists in the current codebase; `sce sync` command wiring is deferred, while local DB initialization and health ownership are split between setup and doctor. - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. @@ -103,7 +103,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/local_db/mod.rs` provides `LocalDb = TursoDb` with `new()`, `execute()`, and `query()` inherited from the shared Turso adapter. - `LocalDb::new()` resolves the canonical per-user DB path through `default_paths::local_db_path()`, creates parent directories, opens the local Turso database, and currently runs zero local migrations. -- `cli/src/services/agent_trace_db/mod.rs` provides `AgentTraceDb = TursoDb` plus `DiffTraceInsert<'_>` and `insert_diff_trace()` for parameterized writes to `diff_traces`, including nullable `model_id` storage. +- `cli/src/services/agent_trace_db/mod.rs` provides `AgentTraceDb = TursoDb` plus `DiffTraceInsert<'_>`/`insert_diff_trace()` for parameterized writes to `diff_traces`, `AgentTraceInsert<'_>`/`insert_agent_trace()` for built `agent_traces`, and `SessionModelUpsert<'_>`/lookup helpers for durable `session_models` attribution keyed by `(tool_name, session_id)`. - `AgentTraceDb::new()` resolves `/sce/agent-trace.db` through `default_paths::agent_trace_db_path()`, creates parent directories through `TursoDb`, opens the Turso database, and runs the ordered embedded `cli/migrations/agent-trace/*.sql` migration set. - `cli/src/services/local_db/lifecycle.rs` implements `ServiceLifecycle` for local DB health checks and setup (DB path/health validation and DB bootstrap). - `cli/src/services/agent_trace_db/lifecycle.rs` implements `ServiceLifecycle` for Agent Trace DB health checks and setup (DB path/health validation and DB bootstrap). diff --git a/context/cli/default-path-catalog.md b/context/cli/default-path-catalog.md index de0a3612..34686af1 100644 --- a/context/cli/default-path-catalog.md +++ b/context/cli/default-path-catalog.md @@ -25,7 +25,7 @@ - `.opencode/`, `.opencode/opencode.json` - `.claude/` - `.git/`, `.git/hooks/`, `.git/COMMIT_EDITMSG` -- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/`, `context/tmp/claude/` +- `context/`, `context/plans/`, `context/decisions/`, `context/handovers/`, `context/tmp/` ### Embedded/install paths @@ -42,6 +42,6 @@ - `cli/src/services/doctor/inspect.rs` also resolves OpenCode manifest/plugin/runtime/preset locations through shared `RepoPaths` and `InstallTargetPaths` accessors instead of owning those paths locally. - `cli/src/services/setup/mod.rs` now resolves setup target directory names and required hook identifiers through `default_paths.rs` constants/accessors instead of owning those path literals locally. - `cli/src/services/default_paths.rs` includes a regression test that scans non-test Rust source under `cli/src/` and fails when new centralized production path literals appear outside the default-path service. -- `cli/src/services/hooks/mod.rs` resolves Claude raw hook capture artifacts through `RepoPaths::claude_capture_tmp_dir()` instead of owning the `context/tmp/claude/` path shape locally. +- `cli/src/services/hooks/mod.rs` resolves the collision-safe `context/tmp/` path shape through shared path accessors. See also: [cli-command-surface.md](./cli-command-surface.md), [../architecture.md](../architecture.md), [../context-map.md](../context-map.md) diff --git a/context/context-map.md b/context/context-map.md index ed9a16c5..7ee3e133 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -43,18 +43,18 @@ Feature/domain context: - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) - `context/sce/local-db.md` (implemented `cli/src/services/local_db/mod.rs` local database spec with `LocalDb = TursoDb`, canonical local DB path resolution, zero local migrations, and inherited blocking `execute`/`query` methods using the shared Turso adapter) - `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, `EncryptedTursoDb` encrypted constructor path with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before keyring-backed encryption key resolution via `encryption_key::get_or_create_encryption_key()`, strict `aegis256` selection via Turso `EncryptionOpts`, shared internal `TursoConnectionCore` operation/migration path for both public adapters, stable `OnceLock` plus atomic retry guard for credential-store default registration without mutex poisoning, platform-specific credential-store remediation mentioning the env fallback, per-database `__sce_migrations` tracking, generic embedded migration execution, and current concrete wrappers for `LocalDb`, `AgentTraceDb`, and encrypted `AuthDb`) -- `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with canonical `/sce/agent-trace.db` path, ordered `diff_traces`, `post_commit_patch_intersections`, `diff_traces(time_ms, id)` index, `agent_traces`, nullable `diff_traces.model_id`, nullable `diff_traces.tool_name`, nullable `diff_traces.tool_version`, and nullable `agent_traces.agent_trace_id` migrations applied through shared migration metadata, typed parameterized insert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, and built `agent_traces` rows with `agent_trace_id` plus schema-validated trace JSON containing range `content_hash`, inclusive bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, and active hook writers for `diff_traces` intake plus post-commit intersection/agent-trace persistence) +- `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with canonical `/sce/agent-trace.db` path, ordered `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, and `session_models` migrations applied through shared migration metadata, typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, built `agent_traces` rows with `agent_trace_id` plus schema-validated trace JSON containing range `content_hash`, and durable session model attribution keyed by `(tool_name, session_id)`, inclusive bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, and active hook writers for `diff_traces` intake, `session_models` upsert via `session-model`, plus post-commit intersection/agent-trace persistence) - `context/sce/auth-db.md` (current encrypted auth DB foundation: canonical `/sce/auth.db` path resolver, `AuthDb = EncryptedTursoDb` wrapper, encryption key resolution from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before OS keyring fallback with no plaintext mode, platform-specific missing/unavailable credential-store remediation that points headless/CI users to the env fallback, baseline migration 001 creating `auth_credentials` without `user_id`, with `updated_at`, and 002 creating the `updated_at` auto-refresh trigger instead of a `user_id` index, and `AuthDbLifecycle` provider registered in the shared lifecycle catalog) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, and always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation as nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and hidden/internal raw Claude JSON capture routing) -- `context/sce/claude-raw-hook-capture.md` (current hidden/internal `sce hooks claude-capture ` CLI intake for raw Claude hook JSON capture plus Pkl-generated `config/.claude/settings.json` registration for `SessionStart`, `UserPromptSubmit`, `PostToolUse` with `Write|Edit|MultiEdit|NotebookEdit`, and `Stop`; includes repo-local `context/tmp/claude/` path ownership, collision-safe artifact naming, invalid JSON no-write behavior, and explicit boundaries excluding Claude diff-trace derivation, AgentTraceDb writes, OpenCode plugin changes, and doctor validation) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and `session-model` STDIN intake for normalized model attribution upsert without raw artifact persistence; the removed `claude-capture` route was deleted in T05) +- `context/sce/claude-raw-hook-capture.md` (removed feature — `claude-capture` CLI route, `ClaudeCaptureEvent`, `claude_transcript.rs`, and `RepoPaths::claude_capture_tmp_dir()` were deleted in T05. Rust now exposes only normalized `session-model` and `diff-trace` intakes.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus the implementation target for generated OpenCode enforcement, including config schema, argv-prefix matching, fixed preset catalog/messages, and precedence rules) -- `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, and TypeScript source ownership; Claude bash-policy enforcement has been removed from generated outputs) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`; `session.diff` event capture has been removed) +- `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and the boundary that Claude uses generated `.claude/settings.json` + Bun runtime registration instead of OpenCode plugin manifests; Claude bash-policy enforcement has been removed from generated outputs) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`, and the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`; `session.diff` event capture has been removed) - `context/sce/cli-first-install-channels-contract.md` (current first-wave `sce` install/distribution contract covering supported channels, canonical naming, `.version` release authority, and Nix-owned build policy) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, and the explicit non-default execution boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, and the current three-target Linux/macOS release workflow topology) diff --git a/context/glossary.md b/context/glossary.md index 7de53254..dd49da84 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -6,13 +6,13 @@ - disposable plan lifecycle: Policy where `context/plans/` holds active execution artifacts only; completed plans are disposable and durable outcomes must be reflected in current-state context files and/or `context/decisions/`. - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. -- generated-owned outputs: Files materialized by `config/pkl/generate.pkl` under `config/.opencode/**`, `config/automated/.opencode/**`, and `config/.claude/**`, including OpenCode plugin entrypoints, generated OpenCode `package.json` manifests, generated OpenCode `opencode.json` manifests, and Claude hook/settings assets. +- generated-owned outputs: Files materialized by `config/pkl/generate.pkl` under `config/.opencode/**`, `config/automated/.opencode/**`, and `config/.claude/**`, including OpenCode plugin entrypoints, generated OpenCode `package.json` manifests, generated OpenCode `opencode.json` manifests, generated Claude plugin entrypoints, and Claude settings assets. - `canonical OpenCode plugin registration source`: Shared Pkl-authored plugin-registration definition in `config/pkl/base/opencode.pkl`, re-exported from `config/pkl/renderers/common.pkl` as the canonical plugin list/path JSON consumed by OpenCode renderers before they emit generated `opencode.json` manifests; the current entries are `sce-bash-policy` and `sce-agent-trace`. - `generated OpenCode plugin registration contract`: Current generated-config contract where `config/.opencode/opencode.json` and `config/automated/.opencode/opencode.json` serialize the OpenCode `plugin` field from canonical Pkl sources for SCE-managed plugins only; the current registered paths are `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts`. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. - `root Biome contract`: Repository-root formatting/linting contract owned by `biome.json`, currently scoped only to `npm/**` and the shared `config/lib/**` plugin package root with package-local `node_modules/**` excluded; the canonical execution path is the root Nix dev shell (`nix develop -c biome ...`). - `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split shared `config/lib/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. -- `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs the bash-policy runtime test at `bash-policy-plugin/bash-policy-runtime.test.ts` from the shared `config/lib/` package root with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. +- `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs Bun-discovered tests from the copied shared `config/lib/` package source (including bash-policy runtime tests and tracked agent-trace plugin tests), with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. - `config-lib shared package root`: Shared Bun/TypeScript package root at `config/lib/` for repository-owned OpenCode plugin support code. It owns `package.json`, `bun.lock`, and `tsconfig.json`, pins `@opencode-ai/plugin` to `1.15.4`, includes both `agent-trace-plugin/**/*.ts` and `bash-policy-plugin/**/*.ts` in strict TypeScript coverage, and excludes package-local `node_modules/` from both TypeScript and root Biome coverage. - `cli rust overlay toolchain`: Toolchain contract in root `flake.nix` that applies `rust-overlay.overlays.default`, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, uses that toolchain across both Crane package and check derivations, and keeps toolchain selection explicit rather than inheriting nixpkgs defaults. - `cli Crane package pipeline`: Current root-flake packaging path in `flake.nix` where `packages.sce` is built through `craneLib.buildDepsOnly` plus `craneLib.buildPackage` against a filtered repo-root source that preserves the Cargo tree and the embedded config/assets required by `cli/build.rs`. @@ -33,8 +33,7 @@ - `encrypted Turso adapter`: Generic adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` (connection, tokio runtime bridge, spec typing). Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, runs embedded migrations after connect, and exposes synchronous `execute`, `query`, `query_map`, and `run_migrations` helpers with `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. -- `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..005`) that creates `diff_traces`, `post_commit_patch_intersections`, and `agent_traces` plus `idx_diff_traces_time_ms_id` and `idx_agent_traces_agent_trace_id`, with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, and built agent-trace rows (including `agent_trace_id`); exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). - - `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..007`) that creates `diff_traces`, `post_commit_patch_intersections`, and `agent_traces`, adds nullable `agent_traces.remote_url`, and creates `idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, and `idx_agent_traces_remote_url`, with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, and built agent-trace rows (including `agent_trace_id`); exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). +- `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..008`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)`, with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, and nullable `tool_version` for parameterized writes to the `diff_traces` table. @@ -101,8 +100,8 @@ - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. - `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and hidden/internal `claude-capture ` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion, and `claude-capture` persists supported raw Claude hook JSON payloads under `context/tmp/claude/` without AgentTraceDb writes. -- `Claude raw hook capture`: Hidden/internal `sce hooks claude-capture ` intake path in `cli/src/services/hooks/mod.rs` for raw Claude hook JSON payloads, plus Pkl-generated Claude project settings at `config/.claude/settings.json` that register exec-form capture handlers for `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop` (`PostToolUse` matches `Write|Edit|MultiEdit|NotebookEdit`). The capture runtime writes pretty JSON artifacts under repo-local `context/tmp/claude/` and intentionally does not derive Claude diff traces, write AgentTraceDb rows, modify OpenCode behavior, or add doctor validation for Claude settings. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `session-model` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion, and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. +- `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes only normalized `session-model` and `diff-trace` intakes for Claude/OpenCode editor runtimes. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `cloud sync gateway placeholder`: Abstraction in `cli/src/services/sync.rs` (`CloudSyncGateway`) that returns deferred cloud-sync checkpoints while `sync` remains non-production. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. - `plan/code overlap map`: Context artifact at `context/sce/plan-code-overlap-map.md` that classifies Shared Context Plan/Code, `/change-to-plan`, `/next-task`, `/commit`, and core skills into role-specific vs shared-reusable instruction blocks with explicit dedup targets. @@ -120,7 +119,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled and SCE is not disabled; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, `diff-trace` is an active intake path (validates required STDIN payload fields including `model_id`, `tool_name`, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb), and hidden `claude-capture` is raw JSON diagnostic capture only under `context/tmp/claude/`. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, `diff-trace` is an active intake path (validates required STDIN payload fields including `model_id`, `tool_name`, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb), and `session-model` is an active intake path (validates required STDIN payload fields including `sessionID`/`model_id`/`tool_name` and upserts into `session_models` without raw artifacts). - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, help/output expose deterministic doctor mode, JSON includes stable problem taxonomy/fixability fields plus database records and fix-result records, the runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode integration presence for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`. Human text mode now uses the approved sectioned layout (`Environment`, `Configuration` (includes Agent Trace DB row), `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and presence-only integration parent/child rows where missing required files surface as `[MISS]` children and `[FAIL]` parent groups. Fix mode still reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. diff --git a/context/overview.md b/context/overview.md index 1089b9dd..6351949d 100644 --- a/context/overview.md +++ b/context/overview.md @@ -24,7 +24,7 @@ Invalid default-discovered config files now also degrade gracefully at startup: The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. -Generated config now includes repo-local OpenCode plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`; the Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. +Generated config now includes repo-local plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config now emits the Bun-run agent-trace runtime at `config/.claude/plugins/sce-agent-trace.ts`; `config/.claude/settings.json` registers `SessionStart`, `UserPromptSubmit`, matched `PostToolUse`, and `Stop` handlers through that runtime, which sends normalized `session-model` payloads to Rust for SessionStart model attribution and forwards supported Claude `PostToolUse` diff traces to `sce hooks diff-trace`. The Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. The `doctor` command now exposes explicit inspection mode (`sce doctor`) and repair-intent mode (`sce doctor --fix`) at the CLI/help/schema level while keeping diagnosis mode read-only. It now validates both current global operator health and the current repo/hook-integrity slice: state-root resolution, global config path resolution, global and repo-local `sce/config.json` readability/schema validity, local DB and Agent Trace DB path + health, DB parent-directory readiness, git availability, non-repo vs bare-repo targeting failures, effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`), hooks-directory health, required hook presence/executable permissions/content drift against canonical embedded SCE-managed hook assets, and repo-root OpenCode integration presence across the installed `plugins`, `agents`, `commands`, and `skills` inventories with embedded SHA-256 content verification for OpenCode assets. Text mode now renders the approved human-only layout with ordered `Environment` / `Configuration` / `Repository` / `Git Hooks` / `Integrations` sections, `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens, shared-style green pass plus red fail/miss coloring when color output is enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows that reflect missing vs content-mismatch states; JSON output now reports Agent Trace DB health under `agent_trace_db` (as a row within the Configuration section in text mode). Repo-scoped database reporting is empty by default because no repo-owned SCE database currently exists. Fix mode reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and can also bootstrap missing canonical DB parent directories while preserving manual-only guidance for unsupported issues. Local database bootstrap is now owned by `LocalDbLifecycle::setup` and `AgentTraceDbLifecycle::setup` aggregated by the setup command, while doctor validates both DB paths/health and can bootstrap missing parent directories. Wiring a user-invocable `sce sync` command is deferred to `0.4.0`. The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable toolchain pinned to `1.93.1` (with `rustfmt` and `clippy`), reads package/check version from the repo-root `.version` file, builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline with filtered package sources for the Cargo tree plus required embedded config/assets, and runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed check derivations (`cargoTest`, `cargoClippy`, `cargoFmt`) that reuse the same filtered source/toolchain setup. @@ -45,10 +45,10 @@ Context sync now uses an important-change gate: cross-cutting/policy/architectur The `/change-to-plan` command body is also intentionally thin orchestration: it delegates clarification and plan-shape contracts to `sce-plan-authoring` (including one-task/one-atomic-commit task slicing) while keeping wrapper-level plan output and handoff obligations explicit. The generated OpenCode command doc now also emits `entry-skill: sce-plan-authoring` plus an ordered `skills` list. The targeted support commands (`handover`, `commit`, `validate`) keep their thin-wrapper behavior and now also emit machine-readable OpenCode command frontmatter describing their entry skill and ordered skill chain. `/commit` is now split by profile: manual generated commands remain proposal-only and allow split guidance when staged changes mix unrelated goals, while the automated OpenCode `/commit` command generates exactly one commit message and runs `git commit` against the staged diff. The shared `sce-atomic-commit` contract also requires commit bodies to cite affected plan slug(s) and updated task ID(s) when staged changes include `context/plans/*.md`, and to stop for clarification instead of inventing those references when the staged plan diff is ambiguous. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. -The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and hidden/internal `claude-capture` captures supported raw Claude hook JSON payloads into `context/tmp/claude/` without AgentTraceDb writes or diff-trace derivation. +The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence, with Claude `model_id` resolved from `session_models` at Rust persistence time. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime now matches the implemented T06 slice for `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, and bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path. -The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support dual-mode operation — local mode via `turso::Builder::new_local()` when `SCE_SYNC_URL`+`SCE_SYNC_TOKEN` are absent, or sync (Turso Cloud) mode via `turso::sync::Builder::new_remote()` when both are set. It owns parent-directory creation, connection setup, tokio current-thread runtime bridging, synchronous `execute`/`query`/`query_map`, generic migration execution, sync operations (`push`/`pull`/`checkpoint`/`stats`) that are no-ops in local mode (sync is never triggered automatically from `execute()`), and shared DB lifecycle helpers for service-specific database wrappers. Auth DB persistence now has a thin encrypted wrapper in `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb` resolves `/sce/auth.db` and embeds ordered `auth_tokens` table/index migrations, with lifecycle registration wired through `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`; auth runtime token-storage is now wired through `token_storage.rs`, which persists tokens via the `auth_credentials` table instead of a JSON file. Agent Trace persistence now has its own `cli/src/services/agent_trace_db/mod.rs` wrapper, canonical `/sce/agent-trace.db` path, a split fresh-start baseline migration set (`001..007`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, and indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`) without `AUTOINCREMENT`, plus `agent_traces.agent_trace_id` as `NOT NULL UNIQUE`; it also provides typed parameterized insert helpers for diff traces, post-commit intersection rows, and built agent-trace rows, chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active `sce hooks diff-trace` writes for `diff_traces`, and active `sce hooks post-commit` writes for built `agent_traces` payloads. -The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and hidden/internal `claude-capture`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` payload persistence with required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames, and `claude-capture` is a raw Claude hook JSON capture path for `SessionStart|UserPromptSubmit|PostToolUse|Stop` payloads under `context/tmp/claude/`. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/claude-raw-hook-capture.md`. +The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support dual-mode operation — local mode via `turso::Builder::new_local()` when `SCE_SYNC_URL`+`SCE_SYNC_TOKEN` are absent, or sync (Turso Cloud) mode via `turso::sync::Builder::new_remote()` when both are set. It owns parent-directory creation, connection setup, tokio current-thread runtime bridging, synchronous `execute`/`query`/`query_map`, generic migration execution, sync operations (`push`/`pull`/`checkpoint`/`stats`) that are no-ops in local mode (sync is never triggered automatically from `execute()`), and shared DB lifecycle helpers for service-specific database wrappers. Auth DB persistence now has a thin encrypted wrapper in `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb` resolves `/sce/auth.db` and embeds ordered `auth_tokens` table/index migrations, with lifecycle registration wired through `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`; auth runtime token-storage is now wired through `token_storage.rs`, which persists tokens via the `auth_credentials` table instead of a JSON file. Agent Trace persistence now has its own `cli/src/services/agent_trace_db/mod.rs` wrapper, canonical `/sce/agent-trace.db` path, a split fresh-start baseline migration set (`001..008`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)` without `AUTOINCREMENT`, plus `agent_traces.agent_trace_id` as `NOT NULL UNIQUE`; it also provides typed parameterized insert helpers for diff traces, post-commit intersection rows, and built agent-trace rows, chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active `sce hooks diff-trace` writes for `diff_traces`, and active `sce hooks post-commit` writes for built `agent_traces` payloads. +The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` payload persistence with required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames; and `session-model` is the active STDIN intake for normalized model attribution upsert. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`), and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--both --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. @@ -108,7 +108,7 @@ Lightweight post-task verification baseline (required after each completed task) - Use `context/sce/agent-trace-retry-queue-observability.md` for the current inactive retry-replay status and retained historical notes about the removed local-hook retry path. - Use `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` for the frozen T01 Local Hooks MVP production contract and deterministic gap matrix that maps current seam-level code truth to the remaining implementation stack (`T02`..`T10`). - Use `context/sce/agent-trace-hooks-command-routing.md` for the implemented T02 `sce hooks` command routing contract (subcommand parsing, deterministic invocation errors, and initial runtime entrypoint behavior). -- Use `context/sce/claude-raw-hook-capture.md` for the current hidden/internal Claude raw hook JSON capture intake, Pkl-generated Claude settings registration, and no-diff-trace/no-AgentTraceDb boundary. +- Use `context/sce/claude-raw-hook-capture.md` (removed feature) for the former hidden/internal Claude raw hook JSON capture intake. The `sce hooks claude-capture` CLI route, `ClaudeCaptureEvent`, `claude_transcript.rs`, and `RepoPaths::claude_capture_tmp_dir()` were removed in T05. Rust now exposes only normalized `session-model` and `diff-trace` intakes. - Use `context/sce/setup-githooks-hook-asset-packaging.md` for the implemented `sce-setup-githooks-any-repo` T02 compile-time hook-template packaging contract and setup-service required-hook embedded accessor surface. - Use `context/sce/setup-githooks-install-flow.md` for the implemented `sce-setup-githooks-any-repo` T03 required-hook install orchestration contract (git-truth hooks-path resolution, per-hook installed/updated/skipped outcomes, and remove-and-replace behavior). - Use `context/sce/setup-githooks-cli-ux.md` for the implemented `sce-setup-githooks-any-repo` T04 setup command-surface contract (`--hooks`, optional `--repo`), compatibility validation rules, and deterministic hook setup messaging. diff --git a/context/patterns.md b/context/patterns.md index fde5953d..325b4448 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -82,7 +82,7 @@ - Keep generated-output parity anchored to `nix run .#pkl-check-generated` and the root `nix flake check` `pkl-parity` derivation; no dedicated generated-parity workflow is currently checked in. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - For non-destructive verification during development, run `nix develop -c pkl eval -m context/tmp/t04-generated config/pkl/generate.pkl` and inspect emitted paths under `context/tmp/`. -- Keep `output.files` limited to generated-owned paths only (`config/{opencode_root}/{agent,command,skills,lib,plugins}`, generated `config/{opencode_root}/package.json`, and `config/{claude_root}/{agents,commands,skills,lib,hooks,settings.json}`, where roots map to `.opencode` and `.claude`). +- Keep `output.files` limited to generated-owned paths only (`config/{opencode_root}/{agent,command,skills,lib,plugins}`, generated `config/{opencode_root}/package.json`, and `config/{claude_root}/{agents,commands,skills,plugins,settings.json}`, where roots map to `.opencode` and `.claude`). - For OpenCode pre-execution tool policy hooks, keep the plugin entrypoint thin (`plugins/*.ts`) and move normalization, config loading, and policy matching logic into `lib/*.ts` so manual and automated profiles regenerate identical enforcement behavior from one canonical TypeScript source. ## Internal subagent parity mapping @@ -134,11 +134,11 @@ - For cross-service CLI dependencies that will be injected through `AppContext`, prefer broad capability traits in `cli/src/services/capabilities.rs` over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `model_id`, `tool_name`, and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text while preserving the artifact fallback; keep hidden `claude-capture` as raw JSON diagnostic capture only under `context/tmp/claude/`, with no Claude diff-trace derivation and no AgentTraceDb writes. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `model_id`, `tool_name`, and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text while preserving the artifact fallback; keep `session-model` as an explicit STDIN intake path for normalized model attribution upsert with no raw artifact persistence. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. - For local hook attribution flows, resolve the top-level enablement gate through the shared config precedence model (`SCE_ATTRIBUTION_HOOKS_ENABLED` over `policies.attribution_hooks.enabled`, default `false`) so commit-msg attribution stays disabled by default without adding hook-specific config parsing. - Do not assume post-commit persistence, retry replay, remap ingestion, or rewrite trace transformation are active in the current local-hook runtime; those paths are removed from the current baseline. -- For the current local DB baseline, resolve one deterministic per-user persistent DB target (Linux: `${XDG_STATE_HOME:-~/.local/state}/sce/local.db`; platform-equivalent state roots elsewhere), keep the path neutral rather than Agent Trace-branded, create parent directories before first use, and route initialization through `LocalDb::new()`. As database services split, keep path/migration ownership in each `DbSpec`: `LocalDbSpec` owns the neutral local DB path with zero migrations, `AuthDbSpec` owns encrypted `/sce/auth.db` plus ordered auth migrations, `AgentTraceDbSpec` owns `/sce/agent-trace.db` plus ordered Agent Trace migrations, and shared Turso mechanics plus migration metadata stay in `TursoDb` / `EncryptedTursoDb`. +- For the current local DB baseline, resolve one deterministic per-user persistent DB target (Linux: `${XDG_STATE_HOME:-~/.local/state}/sce/local.db`; platform-equivalent state roots elsewhere), keep the path neutral rather than Agent Trace-branded, create parent directories before first use, and route initialization through `LocalDb::new()`. As database services split, keep path/migration ownership in each `DbSpec`: `LocalDbSpec` owns the neutral local DB path with zero migrations, `AuthDbSpec` owns encrypted `/sce/auth.db` plus ordered auth migrations, `AgentTraceDbSpec` owns `/sce/agent-trace.db` plus ordered Agent Trace migrations for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, and `session_models`, and shared Turso mechanics plus migration metadata stay in `TursoDb` / `EncryptedTursoDb`. - For hosted event intake seams, verify provider signatures before payload parsing (GitHub `sha256=` HMAC over body, GitLab token-equality secret check), resolve old/new heads from provider payload fields, and derive deterministic reconciliation run idempotency keys from provider+event+repo+head tuple material. - For hosted rewrite mapping seams, resolve candidates deterministically in strict precedence order (patch-id exact, then range-diff score, then fuzzy score), classify top-score ties as `ambiguous`, enforce low-confidence unresolved behavior below `0.60`, and preserve stable outcome ordering via canonical candidate SHA sorting. - For hosted reconciliation observability, publish run-level mapped/unmapped counts, confidence histogram buckets, runtime timing, and normalized error-class labels so retry/quality drift can be monitored without requiring a full dashboard surface. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 87fe4e9b..e79c72ff 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -10,7 +10,7 @@ pub type AgentTraceDb = TursoDb; - `AgentTraceDbSpec`: `DbSpec` implementation for Agent Trace persistence. - `AgentTraceDb`: type alias for `TursoDb`. -- `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: &'a str`, `tool_name: &'a str`, and nullable `tool_version: Option<&'a str>`. +- `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: Option<&'a str>`, `tool_name: &'a str`, and nullable `tool_version: Option<&'a str>`. - `insert_diff_trace()`: domain-specific insert helper using parameterized SQL. - `RecentDiffTracePatches`: parsed recent `diff_traces` query result containing valid parsed patches plus skipped-row reports. - `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)`: chronological `diff_traces` read helper for rows in the inclusive window `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`; parses raw patch text through `parse_patch` and skips malformed rows without failing the query. @@ -18,6 +18,10 @@ pub type AgentTraceDb = TursoDb; - `insert_post_commit_patch_intersection()`: domain-specific insert helper using parameterized SQL. - `AgentTraceInsert<'a>`: insert payload for built Agent Trace rows with `commit_id`, `commit_time_ms`, serialized `trace_json`, `agent_trace_id`, non-null `url`, and required `remote_url: &'a str` (Rust-API-only; DB column stays nullable). - `insert_agent_trace()`: domain-specific insert helper for `agent_traces` using parameterized SQL. +- `SessionModelUpsert<'a>`: upsert payload with `tool_name`, `session_id`, `model_id`, nullable `tool_version`, and `session_start_time_ms`. +- `upsert_session_model()`: domain-specific upsert helper for `session_models` keyed by `(tool_name, session_id)`. +- `SessionModelAttribution`: durable session model attribution row returned from `session_models` lookups. +- `session_model_by_tool_and_session()`: lookup helper for model attribution by `(tool_name, session_id)`. - `lifecycle.rs`: service lifecycle provider for setup/doctor integration. ## Database path @@ -40,6 +44,7 @@ The Agent Trace DB path is resolved from the shared default-path catalog: - `005_create_agent_traces_agent_trace_id_index.sql` - `006_add_agent_traces_vcs_remote_url.sql` (historical filename; migration ID `006_add_agent_traces_remote_url` adds the `remote_url` column) - `007_create_agent_traces_vcs_remote_url_index.sql` (historical filename; migration ID `007_create_agent_traces_remote_url_index` creates `idx_agent_traces_remote_url`) +- `008_create_session_models.sql` The shared `TursoDb` runner records applied IDs in the database-local `__sce_migrations` table. Existing Agent Trace DB files without metadata are brought forward by re-applying the idempotent migration set and recording each ID, so rerunning `sce setup` / `AgentTraceDb::new()` applies later Agent Trace migrations to an already-created `~/.local/state/sce/agent-trace.db`. @@ -77,6 +82,18 @@ The `agent_traces` baseline migration creates: - `agent_trace_id TEXT NOT NULL UNIQUE` - `created_at TEXT NOT NULL DEFAULT (...)` +The `session_models` migration creates durable editor session model attribution: + +- `id INTEGER PRIMARY KEY` +- `tool_name TEXT NOT NULL` +- `session_id TEXT NOT NULL` +- `model_id TEXT NOT NULL` +- `tool_version TEXT` (nullable) +- `session_start_time_ms INTEGER NOT NULL` +- `created_at TEXT NOT NULL DEFAULT (...)` +- `updated_at TEXT NOT NULL DEFAULT (...)` +- `UNIQUE (tool_name, session_id)` + Lookup indexes created by the baseline migration set: - `idx_diff_traces_time_ms_id` on `(time_ms, id)` @@ -96,14 +113,17 @@ Lookup indexes created by the baseline migration set: `sce hooks diff-trace` is the current runtime writer for `diff_traces`. -- The hook path validates required STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` before persistence (`tool_name` non-empty; `tool_version` present and either `null` or non-empty string) and passes parsed `model_id`, `tool_name`, and nullable `tool_version` into `DiffTraceInsert`. +- The hook path validates required STDIN `{ sessionID, diff, time, tool_name, tool_version }` before persistence, with `model_id` accepted as optional (absent or `null`). When `model_id` is absent, Rust resolves it from `session_models` by `(tool_name, session_id)`. If no matching session model row exists, the hook returns success/no-op without artifact or DB writes. +- When `model_id` is present, it passes directly into `DiffTraceInsert` as `Option<&str>` (`Some` for non-empty, `None` for absent/null). - `time` is accepted as a `u64` Unix epoch millisecond input and must fit the signed `i64` `time_ms` column before any persistence starts. -- The hook writes the existing collision-safe `context/tmp/-000000-diff-trace.json` parsed-payload artifact, then attempts to insert the parsed payload fields through `AgentTraceDb::insert_diff_trace()`. +- The hook writes the existing collision-safe `context/tmp/-000000-diff-trace.json` parsed-payload artifact (when model enrichment succeeds or model_id was present), then attempts to insert the parsed payload fields through `AgentTraceDb::insert_diff_trace()`. - Command success requires artifact persistence to succeed; AgentTraceDb open/insert failures are logged and reflected in the success text as failed DB persistence instead of discarding the artifact fallback. - Existing artifact files are not backfilled into the database. Post-commit intersection rows are written by the active `post-commit` hook flow, and the same flow now also inserts built Agent Trace payloads into `agent_traces` via `AgentTraceDb::insert_agent_trace()` (see [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). The persisted `trace_json` is the schema-validated `build_agent_trace(...)` output and includes top-level `metadata.sce.version` from the compiled `sce` CLI package version plus `content_hash` on every emitted range. Range `content_hash` values are computed from the touched-line kind/content of the post-commit hunk that produced the persisted range, not from DB IDs, paths, line positions, or runtime metadata. +The `sce hooks session-model` command route writes normalized session-model attribution payloads into `session_models` via STDIN JSON intake with required `sessionID`/`time`/`model_id`/`tool_name` and nullable `tool_version`. `(tool_name, session_id)` is the unique upsert key: subsequent upserts for the same tool/session pair replace `model_id`, `tool_version`, and `session_start_time_ms` while updating `updated_at`. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). + ## Recent patch reads `AgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 78df42b7..5ff5827a 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -12,7 +12,7 @@ - `sce hooks post-commit [--vcs ] --remote-url ` - `sce hooks post-rewrite ` - `sce hooks diff-trace` -- hidden/internal `sce hooks claude-capture ` for raw Claude JSON capture +- `sce hooks session-model` for normalized model attribution intake ## Parser and dispatch behavior @@ -21,7 +21,6 @@ - `post-commit` now enforces required parse-time validation for `--remote-url` in `cli/src/services/parse/command_runtime.rs`. - `--vcs` remains optional and, when provided, must be one of `git|jj|hg|svn`; unsupported values fail with a validation-classified error. - Missing or blank `--remote-url` fails with a validation-classified error before runtime dispatch. -- `claude-capture` accepts only `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`; unsupported event names fail with validation-classified guidance before runtime dispatch. - Invalid and ambiguous invocations return deterministic actionable errors pointing to `sce hooks --help`. ## Current runtime behavior @@ -57,10 +56,16 @@ - Post-commit Agent Trace success requires both schema validation and Agent Trace DB `agent_traces` persistence to succeed. - Current command-surface success output is: `post-commit hook processed intersection: commit=, intersection_files=`. - `post-rewrite` is a deterministic no-op entrypoint. -- `diff-trace` reads STDIN JSON, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, validates required `tool_version` (must be present and either `null` or a non-empty string), validates required `u64` `time` (Unix epoch milliseconds), rejects `time` values that cannot fit the Agent Trace DB signed `time_ms` column, writes one parsed-payload artifact per invocation to `context/tmp/-000000-diff-trace.json` with atomic create-new retry semantics, and inserts the parsed payload fields into AgentTraceDb via `DiffTraceInsert` + `insert_diff_trace()` including `model_id`. +- `diff-trace` reads STDIN JSON, validates non-empty `sessionID`/`diff`/`tool_name`, optionally accepts `model_id` (absent or `null` → `None`; present non-empty → `Some`), validates required `tool_version` (must be present and either `null` or a non-empty string), validates required `u64` `time` (Unix epoch milliseconds), rejects `time` values that cannot fit the Agent Trace DB signed `time_ms` column. + - When `model_id` is absent from the payload, Rust resolves it from the AgentTraceDb `session_models` table by `(tool_name, session_id)`. If a matching session model row is found, the payload is enriched with the resolved `model_id` before persistence. If no matching row is found, the hook returns success/no-op without writing artifact or DB rows (graceful skip). + - When `model_id` is present in the payload, it is used directly without DB resolution. + - Persistence: writes one parsed-payload artifact per invocation to `context/tmp/-000000-diff-trace.json` with atomic create-new retry semantics (only when model_id is resolved or present), and inserts the parsed payload fields into AgentTraceDb via `DiffTraceInsert` + `insert_diff_trace()` including nullable `model_id`. + - Current TypeScript producers are the OpenCode agent-trace plugin and the generated Claude Bun runtime. + - OpenCode forwards user-message `message.updated` diffs with `tool_name="opencode"`, always including `model_id`, and nullable OpenCode client-version metadata. + - Claude forwards supported `PostToolUse` `Write` create and `Edit` structured-patch diffs with `tool_name="claude"`, omitting `model_id` from the payload and relying on Rust DB resolution. + - Neither TypeScript runtime writes `context/tmp/*-diff-trace.json` artifacts or AgentTraceDb rows directly. - `diff-trace` command success requires artifact persistence to succeed. AgentTraceDb open/insert failures are logged through `sce.hooks.diff_trace.agent_trace_db_write_failed` and reflected in the success text as failed DB persistence, while the parsed-payload artifact remains the durable fallback. -- Hidden/internal `claude-capture` reads STDIN JSON and writes one pretty JSON artifact to `context/tmp/claude/--.json` with atomic create-new retry semantics; invalid JSON fails before persistence, and this route does not write AgentTraceDb or derive diff traces. See [claude-raw-hook-capture.md](claude-raw-hook-capture.md). - +- `session-model` reads STDIN JSON, validates required non-empty `sessionID`/`model_id`/`tool_name`, required `u64` `time` (Unix epoch milliseconds, maps to `session_start_time_ms`), and required nullable/non-empty `tool_version`. Valid payloads are upserted into AgentTraceDb `session_models` via `SessionModelUpsert` using `(tool_name, session_id)` as the unique key. No raw hook artifacts are written. DB open/insert failures are logged through `sce.hooks.session_model.agent_trace_db_write_failed` and reported in the success text as failed persistence. ## Explicit non-goals in the current baseline - No checkpoint handoff file @@ -68,4 +73,4 @@ - No backfill/import of existing `context/tmp/*-diff-trace.json` artifacts into AgentTraceDb - No retry queue replay - No rewrite remap ingestion -- No Claude diff-trace derivation or AgentTraceDb writes from raw Claude capture +- No runtime Claude diff-trace persistence or AgentTraceDb writes from the raw Claude capture route itself, and no direct artifact/DB writes from the Claude or OpenCode TypeScript runtimes diff --git a/context/sce/claude-raw-hook-capture.md b/context/sce/claude-raw-hook-capture.md index 470e124d..ae35c00f 100644 --- a/context/sce/claude-raw-hook-capture.md +++ b/context/sce/claude-raw-hook-capture.md @@ -1,63 +1,31 @@ -# Claude Raw Hook Capture +# Claude Raw Hook Capture (removed) -## Current implemented slice +## Removal summary -- Hidden/internal CLI route: `sce hooks claude-capture `. -- Supported event names are exactly `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`. -- Unsupported event names are rejected during clap-to-runtime conversion with deterministic validation guidance. -- Runtime reads one JSON payload from STDIN, parses it as `serde_json::Value`, enriches `PostToolUse` artifacts with the model identity from the Claude transcript, pretty-prints the (possibly enriched) JSON, and writes one artifact under the active repository's `context/tmp/claude/` directory. -- Invalid JSON fails before the persistence seam, so no malformed capture artifact is written. -- Pkl-generated Claude project settings register capture hooks in `config/.claude/settings.json` for `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`. -- The generated `PostToolUse` hook group matches `Write|Edit|MultiEdit|NotebookEdit`. -- Each generated handler uses Claude Code command-hook exec form with `command: "sce"` and args `hooks`, `claude-capture`, and the event name. +The `sce hooks claude-capture ` CLI route, `ClaudeCaptureEvent`, `HookSubcommand::ClaudeCapture`, the `claude_transcript.rs` enrichment module, and `RepoPaths::claude_capture_tmp_dir()` were removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. -## Path and artifact contract +Rust now exposes only normalized intakes for Claude/OpenCode editor runtimes: -- Repo-local path ownership lives in `cli/src/services/default_paths.rs` as `RepoPaths::claude_capture_tmp_dir()`. -- The path shape is `/context/tmp/claude/`. -- Capture artifacts use the shared hook trace filename pattern: - - `--.json` - - timestamp format: `YYYY-MM-DDTHH-MM-SS-mmmZ` - - attempt is zero-padded to six digits -- Artifact writes use atomic create-new semantics and retry on filename collision up to the shared trace-attempt limit. +- `sce hooks session-model` — STDIN JSON intake for normalized model attribution upsert in `session_models`, keyed by `(tool_name, session_id)`. No raw hook artifacts are written. +- `sce hooks diff-trace` — STDIN JSON intake for normalized diff-trace payloads with optional `model_id`. When `model_id` is absent, Rust resolves it from `session_models` by `(tool_name, session_id)`. Missing model rows skip artifact/DB persistence gracefully. -## Runtime boundaries +## Historical artifact contract -- Claude capture is raw-payload diagnostic storage only. -- Generated settings registration only invokes the raw capture CLI route. -- It does not derive Claude diff traces. -- It does not write to AgentTraceDb. -- It does not modify OpenCode agent-trace behavior or `sce hooks diff-trace` behavior. -- Doctor integration validation for Claude settings remains outside the MVP boundary. +Before removal, the raw capture route: -## Generated settings ownership +- Was a hidden/internal CLI route: `sce hooks claude-capture `. +- Accepted `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`. +- Wrote pretty-printed JSON artifacts to `context/tmp/claude/--.json`. +- Enriched `PostToolUse` artifacts with the model identity from the Claude transcript. +- Did not write AgentTraceDb or derive diff traces. -- `config/pkl/renderers/claude-content.pkl` owns the rendered Claude settings document. -- `config/pkl/generate.pkl` emits that document to `config/.claude/settings.json`. -- The settings file is a generated-owned project-shareable Claude settings artifact and is included in normal generated-output parity checks. +The generated Claude TypeScript runtime at `config/.claude/plugins/sce-agent-trace.ts` previously forwarded the original payload to `sce hooks claude-capture` before deriving normalized `diff-trace` payloads. That raw-capture forwarding was removed in T04 when the TypeScript runtime switched to sending only normalized `session-model` and `diff-trace` payloads. -## PostToolUse model enrichment +## Current state -- `build_claude_capture_artifact` in `cli/src/services/hooks/mod.rs` enriches `PostToolUse` artifacts with the model identity before serialization. -- Enrichment is gated to `PostToolUse` only; `SessionStart`, `UserPromptSubmit`, and `Stop` remain unchanged. -- The enrichment reads `transcript_path` and `tool_use_id` from the STDIN JSON payload, calls `extract_claude_transcript_model`, and injects `"model": ""` as a top-level key in the captured artifact. -- If `transcript_path` or `tool_use_id` are missing, if the transcript is inaccessible, or if no matching assistant message is found, the artifact is written without a `"model"` field and a warning is logged. -- Existing non-model fields in the payload are preserved. +- The Claude TypeScript runtime sends only normalized `session-model` (for `SessionStart` model attribution) and `diff-trace` (for supported `PostToolUse` file-change payloads) payloads to Rust. +- Rust owns normalized persistence: `session-model` upserts into `session_models`, `diff-trace` writes parsed-payload artifacts under `context/tmp/*-diff-trace.json` and inserts into `diff_traces`. +- Claude `diff-trace` `model_id` is resolved from `session_models` at persistence time; OpenCode sends `model_id` directly. +- No raw Claude hook payload artifacts are written by TypeScript or Rust. -## Transcript model extraction helper - -- Defined in `cli/src/services/hooks/claude_transcript.rs` as `extract_claude_transcript_model`. -- Reads a Claude JSONL transcript from an absolute path, scans assistant messages for a `tool_use` content block matching a given `tool_use_id`, and returns the `model` field from that message. -- Returns `None` gracefully for missing/unreadable files, malformed JSONL lines, unmatched IDs, or missing/non-string model fields. -- Wired in T01 of the `claude-capture-enrich-model` plan; activated by the PostToolUse enrichment in T02. -- `tempfile` added as `[dev-dependencies]` for inline transcript fixtures in unit tests. - -## Test contract - -- Parser coverage lives in `cli/src/cli_schema.rs`. -- Runtime conversion coverage lives in `cli/src/services/parse/command_runtime.rs`. -- Capture validation, pretty JSON serialization, invalid JSON no-write behavior, and collision retry coverage live in `cli/src/services/hooks/mod.rs`. -- Unit tests use pure/injected persistence seams rather than real filesystem fixtures so they stay compatible with the Nix sandbox test policy. -- Generated settings parity is covered by `nix run .#pkl-check-generated`; embedded asset compile/test coverage is covered by the CLI test derivation. - -See also: [agent-trace-hooks-command-routing.md](./agent-trace-hooks-command-routing.md), [../cli/default-path-catalog.md](../cli/default-path-catalog.md), [../context-map.md](../context-map.md) +See also: [agent-trace-hooks-command-routing.md](./agent-trace-hooks-command-routing.md), [../context-map.md](../context-map.md) diff --git a/context/sce/generated-opencode-plugin-registration.md b/context/sce/generated-opencode-plugin-registration.md index 0c0f8267..13d0f94a 100644 --- a/context/sce/generated-opencode-plugin-registration.md +++ b/context/sce/generated-opencode-plugin-registration.md @@ -24,6 +24,8 @@ The generated-config pipeline now has one canonical Pkl-authored source for Open ## Claude boundary - Claude does not consume this OpenCode `plugin` manifest surface. +- Claude agent-trace event handling is registered through generated `.claude/settings.json` command hooks that run `bun .claude/plugins/sce-agent-trace.ts `. +- The generated Claude TypeScript runtime is an event adapter only: it sends normalized `session-model` payloads to Rust for `SessionStart` model attribution, forwards supported normalized `diff-trace` payloads to `sce hooks diff-trace`, and Rust remains the artifact and database writer. - Claude bash-policy enforcement has been removed from generated outputs. - OpenCode is now the sole target for SCE-managed bash-policy enforcement via the plugin registration contract. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index 8891a142..470e4659 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -34,3 +34,10 @@ Otherwise, the helper returns `undefined`. - `buildTrace` is now called only for captured event types and exits early unless the event is `message.updated`; if extraction returns `undefined` (non-user role, empty diffs array, or no usable patch entries), no hook invocation occurs. - The plugin tracks OpenCode client version per session ID from `session.created` / `session.updated` events and forwards it as `tool_version` when available. - When extraction succeeds, `buildTrace` forwards the extracted payload with required `tool_name="opencode"` and required `tool_version` (nullable when session version is unavailable) to `sce hooks diff-trace` via STDIN JSON; the Rust hook runtime validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `time`, and persists the DB-backed diff-trace fields through AgentTraceDb `diff_traces` insertion. + +## Shared boundary with Claude runtime + +- OpenCode and Claude now both use generated TypeScript event runtimes as event-shape adapters before handing normalized diff-trace payloads to the shared Rust hook intake. +- OpenCode registration remains the generated OpenCode `opencode.json` plugin manifest; Claude registration remains generated `.claude/settings.json` command hooks that run Bun against `.claude/plugins/sce-agent-trace.ts`. +- The shared Rust boundary is `sce hooks diff-trace`: both runtimes send `{ sessionID, diff, time, model_id, tool_name, tool_version }` over STDIN JSON, and Rust remains the only writer of parsed `context/tmp/*-diff-trace.json` artifacts and AgentTraceDb `diff_traces` rows. +- Claude `model_id` differs from OpenCode attribution: OpenCode reads provider/model data from the OpenCode event, while Claude resolves `model_id` from AgentTraceDb `session_models` at Rust persistence time and skips `diff-trace` persistence when no matching session model row exists. diff --git a/context/sce/shared-turso-db.md b/context/sce/shared-turso-db.md index 95394426..7c65b7a1 100644 --- a/context/sce/shared-turso-db.md +++ b/context/sce/shared-turso-db.md @@ -48,7 +48,7 @@ the secret value. No plaintext fallback exists. The shared module is exported from `cli/src/services/mod.rs` and compile-checked. Current concrete wrappers: - `cli/src/services/local_db/mod.rs`: `LocalDb = TursoDb`, with `LocalDbSpec` resolving `local_db_path()` and declaring zero migrations. -- `cli/src/services/agent_trace_db/mod.rs`: `AgentTraceDb = TursoDb`, with `AgentTraceDbSpec` resolving `agent_trace_db_path()` and loading ordered Agent Trace migrations for `diff_traces` and `post_commit_patch_intersections`. +- `cli/src/services/agent_trace_db/mod.rs`: `AgentTraceDb = TursoDb`, with `AgentTraceDbSpec` resolving `agent_trace_db_path()` and loading ordered Agent Trace migrations for `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, and `session_models`. - `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb`, with `AuthDbSpec` resolving `auth_db_path()` and loading ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. All three database wrappers (local DB, auth DB, Agent Trace DB) have lifecycle providers. `lifecycle_providers(include_hooks)` registers database providers in order `LocalDbLifecycle` → `AuthDbLifecycle` → `AgentTraceDbLifecycle` before optional hooks, so setup initializes all three databases and doctor diagnoses/fixes all three canonical DB paths. diff --git a/flake.nix b/flake.nix index 41ad4663..40975148 100644 --- a/flake.nix +++ b/flake.nix @@ -122,6 +122,8 @@ fileset = pkgs.lib.fileset.unions [ ./config/lib/package.json ./config/lib/bun.lock + ./config/lib/tsconfig.json + ./config/lib/agent-trace-plugin ./config/lib/bash-policy-plugin/bash-policy/runtime.ts ./config/lib/bash-policy-plugin/bash-policy-runtime.test.ts ./config/lib/bash-policy-plugin/opencode-bash-policy-plugin.ts From e2d242bbdc8f6512a1de067e8b1669335d57acd5 Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Mon, 8 Jun 2026 11:23:58 +0200 Subject: [PATCH 03/18] cli: Remove unused tempfile dev dependency Remove the unused CLI dev-dependency from Cargo.toml and Cargo.lock, leaving the crate without declared dev-dependencies. Update the documented CLI dependency and styling baseline to reflect the current manifest state. Co-authored-by: SCE --- cli/Cargo.lock | 1 - cli/Cargo.toml | 3 --- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 2 +- context/context-map.md | 4 ++-- context/glossary.md | 2 +- context/overview.md | 4 ++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2e02531c..6d89a53d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3374,7 +3374,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "tempfile", "tokio", "tracing", "turso", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 836e4a64..43e30545 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -58,9 +58,6 @@ apple-native-keyring-store = { version = "1", features = ["keychain"] } [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = "1" -[dev-dependencies] -tempfile = "3" - [build-dependencies] sha2 = "0.11" diff --git a/context/architecture.md b/context/architecture.md index f121b33a..e90e3909 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -128,7 +128,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/Cargo.toml` now keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`), git (`cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`), and local checkout (`cargo install --path cli --locked`) guidance. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. - `cli/Cargo.toml` now declares Tokio's `time` feature directly alongside the existing constrained current-thread runtime setup (`rt`, `io-util`, `time`) instead of relying on transitive enablement. -This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-subscriber`, `turso`); per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows, while a user-invocable `sce sync` command and broader runtime integrations remain deferred. +This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends); no CLI dev-dependencies are currently declared. Per-user local Turso DB and Agent Trace DB bootstrap/health coverage now exist through setup/doctor flows, while a user-invocable `sce sync` command and broader runtime integrations remain deferred. ## SCE plan/code role boundary diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index f2e5ef44..8e16260e 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -138,7 +138,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D ## Dependency baseline -- `cli/Cargo.toml` currently declares: `anyhow`, `clap`, `clap_complete`, `comfy-table`, `dirs`, `hmac`, `inquire`, `owo-colors`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-subscriber`, and `turso`. +- `cli/Cargo.toml` currently declares runtime dependencies on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, plus target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. - `tokio` is pinned with `default-features = false` and keeps a constrained runtime footprint for current-thread `Runtime::block_on` usage, plus timer-backed bounded retry/timeout behavior in resilience-wrapped operations. - `cli/src/services/auth.rs` provides HTTP-only async functions for the WorkOS Device Authorization Flow (`request_device_authorization`, `poll_for_device_token`, `complete_device_auth_flow_returning_token`) and token-refresh operations (`ensure_valid_token_returning_token`, `renew_stored_token_from_refresh_token`, `is_stored_token_expired`): it requests device codes, polls at fixed API interval (adding 5 seconds on `slow_down`), maps RFC 8628 terminal errors to actionable `Try:` guidance, checks token expiry from persisted `stored_at_unix_seconds + expires_in` with a bounded skew guard, refreshes expired access tokens through `/oauth/token` using `grant_type=refresh_token`, and retries transient refresh failures via the shared resilience wrapper. Token storage (`save_tokens`, `load_tokens`) is called by the caller in `cli/src/services/auth_command/mod.rs` outside of any tokio `block_on` context to avoid nested-runtime panics. diff --git a/context/context-map.md b/context/context-map.md index 7ee3e133..b9f82944 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -13,8 +13,8 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json`/`load_patch_from_json_bytes` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) -- `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors` and `comfy-table`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) -- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, and trimmed `validate` output contract) +- `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) +- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe `ServiceLifecycle` seam in `cli/src/services/lifecycle.rs`, including default no-op diagnose/fix/setup methods against `AppContext`, lifecycle-owned health/fix/setup result types, doctor/setup adapter boundaries, the shared lifecycle provider catalog/factory, hook/config/local_db/auth_db/agent_trace_db lifecycle providers, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) - `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with env-over-config fallback, concrete logger/telemetry runtime behavior plus logger and object-safe telemetry trait boundaries, AppContext observability wiring including runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, and the trimmed `sce config validate` status-only validation surface) diff --git a/context/glossary.md b/context/glossary.md index dd49da84..21629448 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -28,7 +28,7 @@ - `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `auth` and `hooks`, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. - `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, and returns deterministic actionable errors for invalid invocation. - `RuntimeCommand seam`: Internal command-execution abstraction where clap-parsed commands are converted into boxed command objects with `name()` and `execute(&AppContext)` methods, allowing app lifecycle orchestration to log and run commands without a single central dispatch `match` covering every command; the `RuntimeCommand` trait and `RuntimeCommandHandle` type alias are defined in `cli/src/services/command_registry.rs`, and the `CommandRegistry` struct maps command names to zero-arg constructor functions for dispatch. Migrated commands (`HelpCommand`, `HelpTextCommand`, `VersionCommand`, `CompletionCommand`, `AuthCommand`, `ConfigCommand`, `SetupCommand`, `DoctorCommand`, `HooksCommand`) live in service-owned `command.rs` files; parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. -- `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-subscriber`, `turso`) and validated through normal compile/test coverage. +- `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends). No CLI dev-dependencies are currently declared, and the baseline is validated through normal compile/test coverage. - `local Turso adapter`: Module in `cli/src/services/local_db/mod.rs` that defines `LocalDbSpec` and exposes `LocalDb` as a `TursoDb` alias. It resolves the canonical local DB path with `local_db_path()`, currently declares zero migrations, and inherits `new()`, `execute()`, and `query()` from the shared generic adapter. - `encrypted Turso adapter`: Generic adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` (connection, tokio runtime bridge, spec typing). Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, runs embedded migrations after connect, and exposes synchronous `execute`, `query`, `query_map`, and `run_migrations` helpers with `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. diff --git a/context/overview.md b/context/overview.md index 6351949d..3654192f 100644 --- a/context/overview.md +++ b/context/overview.md @@ -6,14 +6,14 @@ It now supports both manual and automated profile variants: the manual profile p It also includes an early Rust CLI foundation at `cli/` for Shared Context Engineering workflows. Operator-facing CLI usage currently comes from a slimmed top-level `sce --help` surface, command-local `--help` output, and focused context files under `context/cli/` and `context/sce/`. -The CLI crate currently depends on `anyhow`, `clap`, `clap_complete`, `comfy-table`, `dirs`, `hmac`, `inquire`, `owo-colors`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-subscriber`, and `turso`. +The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help now displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan→magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels, and hides `auth` and `hooks` from `sce`, `sce help`, and `sce --help`, while those commands remain directly invocable. The real top-level command catalog/help-visibility contract is now centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|status`) plus auth-local guidance for bare `sce auth` / `sce auth --help`, implemented config inspection/validation (`config show`/`config validate`) with bare `sce config` routing to the same help payload as `sce config --help`, real setup orchestration, implemented `doctor` diagnosis-vs-fix CLI surface and stable output-shape scaffolding (`sce doctor`, `sce doctor --fix`, `--format text|json`) plus current installed-CLI/global-state diagnostics for state-root resolution, global config validation, local DB and Agent Trace DB path + health, writable DB-parent-path checks, git availability/repository targeting, bare-repo refusal, effective hook-path source detection, an intentionally empty repo-scoped SCE database section for the active repository, required-hook presence/executable/content-drift checks against canonical embedded SCE-managed hook assets, repair-mode reuse of canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories, and doctor-owned bootstrap repair for missing canonical DB parent directories, implemented attribution-only `hooks` subcommand routing/validation entrypoints with commit-msg-only behavior behind a disabled-default gate, implemented machine-readable runtime identification (`version`), implemented shell completion script generation via `clap_complete` (`completion --shell `), and placeholder dispatch for deferred commands (`sync`) through explicit service contracts. Parse-time command conversion plus run-time command handling now flow through an internal `RuntimeCommand` seam in `cli/src/app.rs`, so top-level app orchestration no longer owns one monolithic dispatch `match` for every command. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic `truncate` default), stable lifecycle event IDs, stderr-only primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry runtime dependency injection. The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics are emitted on stderr. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-registry state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, and `services::app_support::execute_command_phase` delegates to the command's own execution method instead of a central app-level `dispatch` function. Command structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, and `hooks` live in service-owned `command.rs` files; `build_default_registry()` registers the migrated command constructors while parse-time conversion constructs stateful commands with user-provided options. The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against `AppContext`, uses lifecycle-owned health/fix/setup result types, and owns the shared lifecycle provider catalog/factory with deterministic config → local_db → auth_db → agent_trace_db → hooks ordering. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for canonical Agent Trace DB path health, parent-directory readiness/bootstrap, and `AgentTraceDb::new()` setup. Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. -The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors` and table rendering via `comfy-table`, with automatic TTY detection and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports `supports_color()`, `supports_color_stderr()`, `table()`, `style_if_enabled()`, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. +The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/both with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--both`), deterministic mutually-exclusive validation, and non-destructive cancellation exits. The CLI now compiles an embedded setup asset manifest from `config/.opencode/**`, `config/.claude/**`, and `cli/assets/hooks/**` via `cli/build.rs`; `cli/src/services/setup/mod.rs` exposes deterministic normalized relative paths plus file bytes and target-scoped iteration without runtime reads from `config/`. The setup service also provides repository-root install orchestration: it resolves the repository root, derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup uses a unified remove-and-replace policy for all write flows — it removes existing targets before swapping staged content and returns deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs` now separates install-flow logic from interactive prompt logic through focused support seams. From d8169cff647dc36edbf9021a460f7183d799d190 Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Mon, 8 Jun 2026 15:37:59 +0200 Subject: [PATCH 04/18] hooks: Make model_id optional with session_models resolution Claude sends model attribution via `session-model` at SessionStart rather than including model_id in every diff-trace payload. The parser previously required model_id, rejecting all Claude diff traces. - Change DiffTraceInsert.model_id from &str to Option<&str> - Change DiffTracePayload.model_id from String to Option - Add optional_string_field helper for JSON parsing - Wire resolve_model closure for DB lookup when model_id absent - Thread resolved model_id through persistence functions Co-authored-by: SCE --- cli/src/services/agent_trace_db/mod.rs | 4 +- cli/src/services/hooks/mod.rs | 63 +++++++++++++++---- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 4 +- context/glossary.md | 4 +- context/overview.md | 4 +- context/patterns.md | 2 +- .../opencode-agent-trace-plugin-runtime.md | 2 +- 8 files changed, 61 insertions(+), 24 deletions(-) diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 0ce181f3..19e7aa68 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -135,7 +135,7 @@ pub struct DiffTraceInsert<'a> { pub time_ms: i64, pub session_id: &'a str, pub patch: &'a str, - pub model_id: &'a str, + pub model_id: Option<&'a str>, pub tool_name: &'a str, pub tool_version: Option<&'a str>, } @@ -523,7 +523,7 @@ mod tests { time_ms, session_id, patch, - model_id: "test-provider/test-model", + model_id: Some("test-provider/test-model"), tool_name: "opencode", tool_version: Some("1.2.3"), }, diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 772c2517..7fd975a5 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -66,16 +66,17 @@ struct DiffTracePayload { session_id: String, diff: String, time: u64, - model_id: String, + model_id: Option, tool_name: String, tool_version: Option, } /// Required `sce hooks diff-trace` STDIN payload shape: -/// `{ sessionID, diff, time, model_id, tool_name, tool_version }`. +/// `{ sessionID, diff, time, model_id?, tool_name, tool_version }`. /// /// Validation contract: -/// - `sessionID`, `diff`, `model_id`, and `tool_name` must be non-empty strings. +/// - `sessionID`, `diff`, and `tool_name` must be non-empty strings. +/// - `model_id` is optional: absent or `null` → `None`, present+non-empty → `Some`, present+empty → error. /// - `time` must be a `u64` Unix epoch millisecond value. /// - `tool_version` must be present and either `null` or a non-empty string. pub fn run_hooks_subcommand( @@ -143,19 +144,23 @@ fn run_diff_trace_subcommand_from_payload( Ok(attribution.map(|a| a.model_id)) }; - run_diff_trace_subcommand_from_payload_with(repository_root, payload, logger, resolve_model) + run_diff_trace_subcommand_from_payload_with(repository_root, &payload, logger, resolve_model) } fn run_diff_trace_subcommand_from_payload_with( repository_root: &Path, - payload: DiffTracePayload, + payload: &DiffTracePayload, logger: Option<&dyn Logger>, - _resolve_model: R, + resolve_model: R, ) -> Result where R: FnOnce(&str, &str) -> Result>, { - // model_id is required from the caller; no resolution needed. + let resolved_model_id: Option = match &payload.model_id { + Some(id) => Some(id.clone()), + None => resolve_model(&payload.tool_name, &payload.session_id)?, + }; + if let Err(error) = diff_trace_db_time_ms(payload.time) { if let Some(log) = logger { log.warn( @@ -165,8 +170,9 @@ where ); } } - persist_diff_trace_payload(repository_root, &payload)?; - let agent_trace_db_result = persist_diff_trace_payload_to_agent_trace_db(&payload); + persist_diff_trace_payload(repository_root, payload)?; + let agent_trace_db_result = + persist_diff_trace_payload_to_agent_trace_db(payload, resolved_model_id.as_deref()); let agent_trace_db_persisted = match agent_trace_db_result { Ok(()) => true, Err(error) => { @@ -263,7 +269,7 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result { let session_id = required_non_empty_string_field(payload, "sessionID")?; let diff = required_non_empty_string_field(payload, "diff")?; let time = required_u64_millisecond_field(payload, "time")?; - let model_id = required_non_empty_string_field(payload, "model_id")?; + let model_id = optional_string_field(payload, "model_id")?; let tool_name = required_non_empty_string_field(payload, "tool_name")?; let tool_version = required_nullable_or_non_empty_string_field(payload, "tool_version")?; @@ -419,6 +425,33 @@ fn required_nullable_or_non_empty_string_field( Ok(Some(value.to_string())) } +fn optional_string_field( + payload: &serde_json::Map, + field_name: &str, +) -> Result> { + let Some(raw) = payload.get(field_name) else { + return Ok(None); + }; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(diff_trace_validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(diff_trace_validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + fn required_non_empty_string_field( payload: &serde_json::Map, field_name: &str, @@ -522,8 +555,11 @@ fn persist_diff_trace_payload( ) } -fn persist_diff_trace_payload_to_agent_trace_db(payload: &DiffTracePayload) -> Result<()> { - persist_diff_trace_payload_to_agent_trace_db_with(payload, |input| { +fn persist_diff_trace_payload_to_agent_trace_db( + payload: &DiffTracePayload, + model_id: Option<&str>, +) -> Result<()> { + persist_diff_trace_payload_to_agent_trace_db_with(payload, model_id, |input| { let db = AgentTraceDb::new() .context("Failed to open Agent Trace DB for diff-trace persistence.")?; db.insert_diff_trace(input) @@ -535,6 +571,7 @@ fn persist_diff_trace_payload_to_agent_trace_db(payload: &DiffTracePayload) -> R fn persist_diff_trace_payload_to_agent_trace_db_with( payload: &DiffTracePayload, + model_id: Option<&str>, insert_fn: F, ) -> Result<()> where @@ -546,7 +583,7 @@ where time_ms, session_id: &payload.session_id, patch: &payload.diff, - model_id: &payload.model_id, + model_id, tool_name: &payload.tool_name, tool_version: payload.tool_version.as_deref(), }) diff --git a/context/architecture.md b/context/architecture.md index e90e3909..9e420001 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -116,7 +116,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns `DoctorCommand` and its `RuntimeCommand` impl. Runtime doctor execution receives `AppContext`, requests the shared lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Report fact collection still preserves current environment/repository/hook/integration display data, while service-owned lifecycle providers now own config validation, local DB and Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` struct and its `RuntimeCommand` impl. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` struct and its `RuntimeCommand` impl. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name` and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb; `session-model` performs STDIN JSON intake, validates required non-empty `sessionID`/`model_id`/`tool_name`, required `u64` `time` (Unix epoch milliseconds), and required nullable/non-empty `tool_version`, then upserts the parsed payload into AgentTraceDb `session_models` without writing raw hook artifacts. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the disabled-default attribution-hooks config/env control is enabled and `SCE_DISABLED` is false; `cli/src/services/hooks/command.rs` owns `HooksCommand` and its `RuntimeCommand` impl. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in AgentTraceDb without post-commit file artifacts); `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id` when absent) and required `tool_version` (present and either `null` or non-empty string) plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit AgentTraceDb signed `time_ms` storage, writes one collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifact, and inserts the parsed payload fields into AgentTraceDb; `session-model` performs STDIN JSON intake, validates required non-empty `sessionID`/`model_id`/`tool_name`, required `u64` `time` (Unix epoch milliseconds), and required nullable/non-empty `tool_version`, then upserts the parsed payload into AgentTraceDb `session_models` without writing raw hook artifacts. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - No user-invocable `sce sync` command is wired in the current runtime; local DB and Agent Trace DB bootstrap flows through lifecycle providers aggregated by setup, and DB health/repair flows through the doctor surface. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 8e16260e..cd2bd8ce 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -53,7 +53,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - `auth` and `hooks` stay parser-valid and directly invocable, but are hidden from those top-level help surfaces Deferred or gated command surfaces currently avoid claiming unimplemented behavior. -`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including `model_id`, and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence. +`hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; current behavior remains attribution-only and disabled by default for commit attribution, while `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path, `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id` when absent), required `tool_version` (present and either `null` or non-empty string), plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation `context/tmp/-000000-diff-trace.json` parsed-payload writes, and AgentTraceDb insertion including nullable `model_id`, and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence. `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. @@ -91,7 +91,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, an empty default repo-scoped database inventory, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode integration presence inventory for `plugins`, `agents`, `commands`, and `skills` derived from the embedded OpenCode setup asset catalog, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. -- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text; and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). +- `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the disabled-default attribution gate; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path (captures current commit patch, combines/intersects recent `diff_traces`, persists intersection metadata to `post_commit_patch_intersections`, then persists built Agent Trace payload with range-level `content_hash` values to `agent_traces`); `diff-trace` performs STDIN JSON intake, required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id` when absent), required nullable/non-empty `tool_version`, plus required `u64` `time` (Unix epoch milliseconds) validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` persistence, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text; and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - No `cli/src/services/sync.rs` module exists in the current codebase; `sce sync` command wiring is deferred, while local DB initialization and health ownership are split between setup and doctor. - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. diff --git a/context/glossary.md b/context/glossary.md index 21629448..511049ce 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -100,7 +100,7 @@ - `auth config baked default`: Optional key-declared fallback in `cli/src/services/config/mod.rs` (with schema/parsing in `schema.rs`) used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: Installer in `cli/src/services/setup/mod.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations, using a unified remove-and-replace policy that removes existing targets before swapping staged content. - `setup remove-and-replace`: Replacement choreography in `cli/src/services/setup/mod.rs` where existing install targets are removed before staged content is promoted; on swap failure, the engine cleans temporary staging paths and returns deterministic recovery guidance (recover from version control). No backup artifacts are created. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `session-model` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion, and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) that supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, and `session-model` with deterministic invocation validation/usage errors; `commit-msg` is the only active attribution path behind the attribution hooks gate, `pre-commit`/`post-rewrite` are deterministic no-op entrypoints, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures the current commit patch, queries recent `diff_traces` from the past 7 days, combines valid patches via `patch::combine_patches`, intersects with the post-commit patch via `patch::intersect_patches`, persists the intersection result to `post_commit_patch_intersections`, and persists built Agent Trace payloads to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact), `diff-trace` performs STDIN JSON intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id` when absent), required `tool_version` (present and either `null` or non-empty string), required `u64` `time` validation, non-lossy AgentTraceDb `time_ms` conversion, collision-safe per-invocation artifact persistence at `context/tmp/-000000-diff-trace.json`, and AgentTraceDb insertion, and `session-model` performs STDIN JSON intake for normalized model attribution upsert without raw artifact persistence. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes only normalized `session-model` and `diff-trace` intakes for Claude/OpenCode editor runtimes. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `cloud sync gateway placeholder`: Abstraction in `cli/src/services/sync.rs` (`CloudSyncGateway`) that returns deferred cloud-sync checkpoints while `sync` remains non-production. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -119,7 +119,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled and SCE is not disabled; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, `diff-trace` is an active intake path (validates required STDIN payload fields including `model_id`, `tool_name`, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb), and `session-model` is an active intake path (validates required STDIN payload fields including `sessionID`/`model_id`/`tool_name` and upserts into `session_models` without raw artifacts). +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path behind the disabled-default attribution-hooks control, `post-commit` requires validated `--remote-url`, threads that value through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists to `post_commit_patch_intersections`, and persists built Agent Trace payloads to `agent_traces` without post-commit file artifacts, `diff-trace` is an active intake path (validates required STDIN payload fields including `sessionID`/`diff`/`tool_name`, optional `model_id` resolved from `session_models` when absent, and required nullable/non-empty `tool_version`, writes collision-safe parsed-payload `context/tmp/-000000-diff-trace.json` artifacts, and inserts parsed payload fields into AgentTraceDb), and `session-model` is an active intake path (validates required STDIN payload fields including `sessionID`/`model_id`/`tool_name` and upserts into `session_models` without raw artifacts). - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, help/output expose deterministic doctor mode, JSON includes stable problem taxonomy/fixability fields plus database records and fix-result records, the runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode integration presence for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`. Human text mode now uses the approved sectioned layout (`Environment`, `Configuration` (includes Agent Trace DB row), `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and presence-only integration parent/child rows where missing required files surface as `[MISS]` children and `[FAIL]` parent groups. Fix mode still reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. diff --git a/context/overview.md b/context/overview.md index 3654192f..b5bb1809 100644 --- a/context/overview.md +++ b/context/overview.md @@ -45,10 +45,10 @@ Context sync now uses an important-change gate: cross-cutting/policy/architectur The `/change-to-plan` command body is also intentionally thin orchestration: it delegates clarification and plan-shape contracts to `sce-plan-authoring` (including one-task/one-atomic-commit task slicing) while keeping wrapper-level plan output and handoff obligations explicit. The generated OpenCode command doc now also emits `entry-skill: sce-plan-authoring` plus an ordered `skills` list. The targeted support commands (`handover`, `commit`, `validate`) keep their thin-wrapper behavior and now also emit machine-readable OpenCode command frontmatter describing their entry skill and ordered skill chain. `/commit` is now split by profile: manual generated commands remain proposal-only and allow split guidance when staged changes mix unrelated goals, while the automated OpenCode `/commit` command generates exactly one commit message and runs `git commit` against the staged diff. The shared `sce-atomic-commit` contract also requires commit bodies to cite affected plan slug(s) and updated task ID(s) when staged changes include `context/plans/*.md`, and to stop for clarification instead of inventing those references when the staged plan diff is ambiguous. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. -The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence, with Claude `model_id` resolved from `session_models` at Rust persistence time. +The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error — resolved from `session_models` by `tool_name` + `session_id` when absent), required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence, with Claude `model_id` resolved from `session_models` at Rust persistence time. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime now matches the implemented T06 slice for `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, and bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support dual-mode operation — local mode via `turso::Builder::new_local()` when `SCE_SYNC_URL`+`SCE_SYNC_TOKEN` are absent, or sync (Turso Cloud) mode via `turso::sync::Builder::new_remote()` when both are set. It owns parent-directory creation, connection setup, tokio current-thread runtime bridging, synchronous `execute`/`query`/`query_map`, generic migration execution, sync operations (`push`/`pull`/`checkpoint`/`stats`) that are no-ops in local mode (sync is never triggered automatically from `execute()`), and shared DB lifecycle helpers for service-specific database wrappers. Auth DB persistence now has a thin encrypted wrapper in `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb` resolves `/sce/auth.db` and embeds ordered `auth_tokens` table/index migrations, with lifecycle registration wired through `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`; auth runtime token-storage is now wired through `token_storage.rs`, which persists tokens via the `auth_credentials` table instead of a JSON file. Agent Trace persistence now has its own `cli/src/services/agent_trace_db/mod.rs` wrapper, canonical `/sce/agent-trace.db` path, a split fresh-start baseline migration set (`001..008`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)` without `AUTOINCREMENT`, plus `agent_traces.agent_trace_id` as `NOT NULL UNIQUE`; it also provides typed parameterized insert helpers for diff traces, post-commit intersection rows, and built agent-trace rows, chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active `sce hooks diff-trace` writes for `diff_traces`, and active `sce hooks post-commit` writes for built `agent_traces` payloads. -The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id, tool_name, tool_version }` payload persistence with required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames; and `session-model` is the active STDIN intake for normalized model attribution upsert. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. +The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id`), required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames; and `session-model` is the active STDIN intake for normalized model attribution upsert. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`), and uses a unified remove-and-replace policy that removes existing hooks before swapping staged content with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--both --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. diff --git a/context/patterns.md b/context/patterns.md index 325b4448..d2fb6b46 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -134,7 +134,7 @@ - For cross-service CLI dependencies that will be injected through `AppContext`, prefer broad capability traits in `cli/src/services/capabilities.rs` over one-off per-service abstractions; keep production wrappers thin over `std::fs` and `git` process execution until call-site migration tasks approve deeper service refactors. - For future CLI domains, define trait-first service contracts with request/plan models in `cli/src/services/*` and keep placeholder implementations explicitly non-runnable until production behavior is approved. - Model deferred integration boundaries with concrete event/capability data structures (for example hook-runtime attribution snapshots/policies and cloud-sync checkpoints) so later tasks can implement behavior without reshaping public seams. -- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `model_id`, `tool_name`, and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text while preserving the artifact fallback; keep `session-model` as an explicit STDIN intake path for normalized model attribution upsert with no raw artifact persistence. +- For the current local-hook baseline, keep `pre-commit` and `post-rewrite` as deterministic no-op entrypoints; keep `post-commit` as the active bounded recent-diff-trace intersection entrypoint with validated `--remote-url` plumbed through Agent Trace flow and any direct diagnostics printed to stderr; keep `diff-trace` as an explicit STDIN intake path with deterministic required-field validation for `sessionID`, `diff`, `time`, `tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id` when absent), and `tool_version` (present and either `null` or non-empty string), non-lossy AgentTraceDb `time_ms` conversion, collision-safe `context/tmp/-000000-diff-trace.json` persistence using atomic create-new retry semantics, and best-effort AgentTraceDb insertion whose failure is logged and reflected in success text while preserving the artifact fallback; keep `session-model` as an explicit STDIN intake path for normalized model attribution upsert with no raw artifact persistence. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. - For local hook attribution flows, resolve the top-level enablement gate through the shared config precedence model (`SCE_ATTRIBUTION_HOOKS_ENABLED` over `policies.attribution_hooks.enabled`, default `false`) so commit-msg attribution stays disabled by default without adding hook-specific config parsing. - Do not assume post-commit persistence, retry replay, remap ingestion, or rewrite trace transformation are active in the current local-hook runtime; those paths are removed from the current baseline. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index 470e4659..e14bea3e 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -33,7 +33,7 @@ Otherwise, the helper returns `undefined`. - The extraction seam is internal to the source module and is used by `buildTrace` at runtime. - `buildTrace` is now called only for captured event types and exits early unless the event is `message.updated`; if extraction returns `undefined` (non-user role, empty diffs array, or no usable patch entries), no hook invocation occurs. - The plugin tracks OpenCode client version per session ID from `session.created` / `session.updated` events and forwards it as `tool_version` when available. -- When extraction succeeds, `buildTrace` forwards the extracted payload with required `tool_name="opencode"` and required `tool_version` (nullable when session version is unavailable) to `sce hooks diff-trace` via STDIN JSON; the Rust hook runtime validates required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, plus required `time`, and persists the DB-backed diff-trace fields through AgentTraceDb `diff_traces` insertion. +- When extraction succeeds, `buildTrace` forwards the extracted payload with required `tool_name="opencode"` and required `tool_version` (nullable when session version is unavailable) to `sce hooks diff-trace` via STDIN JSON; the Rust hook runtime validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`, resolved from `session_models` when absent), required nullable/non-empty `tool_version`, plus required `time`, and persists the DB-backed diff-trace fields (including nullable `model_id`) through AgentTraceDb `diff_traces` insertion. ## Shared boundary with Claude runtime From f72fd3d4a30ca794507e668b0437aa2d4529f2a8 Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Tue, 9 Jun 2026 15:03:12 +0200 Subject: [PATCH 05/18] agent-trace: Add Claude derivation golden tests Add checked-in diff_creation fixtures for Claude Write/Edit PostToolUse payloads and test deriveClaudeDiffTracePayload through the exported derivation seam. Cover create and edit scenarios with exact expected patch comparisons, and update Agent Trace and patch-service context to document the fixture coverage. Co-authored-by: SCE --- .../edit_multi_hunk/claude-post-tool-use.json | 45 ++++++++ .../edit_multi_hunk/expected.patch | 21 ++++ .../diff_creation/edit_multi_hunk/input.json | 7 ++ .../claude-post-tool-use.json | 42 +++++++ .../edit_only_additions/expected.patch | 10 ++ .../edit_only_additions/input.json | 6 + .../claude-post-tool-use.json | 42 +++++++ .../edit_only_deletions/expected.patch | 10 ++ .../edit_only_deletions/input.json | 6 + .../claude-post-tool-use.json | 31 ++++++ .../edit_single_hunk/expected.patch | 14 +++ .../diff_creation/edit_single_hunk/input.json | 6 + .../claude-post-tool-use.json | 12 ++ .../write_create_empty/expected.patch | 4 + .../write_create_empty/input.json | 4 + .../claude-post-tool-use.json | 12 ++ .../write_create_multiline/expected.patch | 29 +++++ .../write_create_multiline/input.json | 4 + .../claude-post-tool-use.json | 12 ++ .../write_create_no_newline/expected.patch | 6 + .../write_create_no_newline/input.json | 4 + .../claude-post-tool-use.json | 12 ++ .../write_create_simple/expected.patch | 10 ++ .../write_create_simple/input.json | 4 + .../claude-sce-agent-trace-plugin.test.ts | 103 ++++++++++++++++++ context/cli/patch-service.md | 9 +- context/context-map.md | 6 +- .../opencode-agent-trace-plugin-runtime.md | 12 +- 28 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch create mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json create mode 100644 config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json new file mode 100644 index 00000000..9e157ccf --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json @@ -0,0 +1,45 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-edit-multi-hunk", + "tool_name": "Edit", + "tool_input": { + "file_path": "poem.md" + }, + "tool_response": { + "structuredPatch": { + "hunks": [ + { + "oldStart": 1, + "oldCount": 8, + "newStart": 1, + "newCount": 8, + "lines": [ + " # Q Moment of Code", + " ", + " In circuits deep where logic flows,", + "-A thousand silent processes glow,", + "+Q thousand silent processes glow,", + " Through paths of thought, both near and far,", + " We build the world the way things are.", + " ", + " Each line we write, each function named," + ] + }, + { + "oldStart": 20, + "oldCount": 5, + "newStart": 20, + "newCount": 5, + "lines": [ + " And in this dance of bit and byte,", + " We shape tomorrow's morning light.", + " ", + " ---", + "-*Written with ❤️ and a touch of caffeine*", + "+*Written with ❤️ and a touch of caffeinw*" + ] + } + ] + } + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch new file mode 100644 index 00000000..64174f95 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch @@ -0,0 +1,21 @@ +Index: poem.md +=================================================================== +--- a/poem.md ++++ b/poem.md +@@ -1,8 +1,8 @@ + # Q Moment of Code + + In circuits deep where logic flows, +-A thousand silent processes glow, ++Q thousand silent processes glow, + Through paths of thought, both near and far, + We build the world the way things are. + + Each line we write, each function named, +@@ -20,5 +20,5 @@ + And in this dance of bit and byte, + We shape tomorrow's morning light. + + --- +-*Written with ❤️ and a touch of caffeine* ++*Written with ❤️ and a touch of caffeinw* diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json new file mode 100644 index 00000000..00ef780b --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json @@ -0,0 +1,7 @@ +{ + "filePath": "poem.md", + "renderedHunks": [ + "@@ -1,8 +1,8 @@\n # Q Moment of Code\n \n In circuits deep where logic flows,\n -A thousand silent processes glow,\n +Q thousand silent processes glow,\n Through paths of thought, both near and far,\n We build the world the way things are.\n \n Each line we write, each function named,", + "@@ -20,5 +20,5 @@\n And in this dance of bit and byte,\n We shape tomorrow's morning light.\n \n ---\n -*Written with ❤️ and a touch of caffeine*\n +*Written with ❤️ and a touch of caffeinw*" + ] +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json new file mode 100644 index 00000000..d232bd7e --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json @@ -0,0 +1,42 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-edit-only-additions", + "tool_name": "Edit", + "tool_input": { + "file_path": "main.ts" + }, + "tool_response": { + "structuredPatch": { + "hunks": [ + { + "oldStart": 1, + "oldCount": 3, + "newStart": 1, + "newCount": 5, + "lines": [ + { + "kind": "context", + "content": "import { foo } from \"./foo\";" + }, + { + "kind": "context", + "content": "import { bar } from \"./bar\";" + }, + { + "kind": "context", + "content": "" + }, + { + "kind": "added", + "content": "import { baz } from \"./baz\";" + }, + { + "kind": "added", + "content": "import { qux } from \"./qux\";" + } + ] + } + ] + } + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch new file mode 100644 index 00000000..c9c1965f --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch @@ -0,0 +1,10 @@ +Index: main.ts +=================================================================== +--- a/main.ts ++++ b/main.ts +@@ -1,3 +1,5 @@ + import { foo } from "./foo"; + import { bar } from "./bar"; + ++import { baz } from "./baz"; ++import { qux } from "./qux"; diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json new file mode 100644 index 00000000..b6fb6fbe --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json @@ -0,0 +1,6 @@ +{ + "filePath": "main.ts", + "renderedHunks": [ + "@@ -1,3 +1,5 @@\n import { foo } from \"./foo\";\n import { bar } from \"./bar\";\n \n+import { baz } from \"./baz\";\n+import { qux } from \"./qux\";" + ] +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json new file mode 100644 index 00000000..5cbdab8f --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json @@ -0,0 +1,42 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-edit-only-deletions", + "tool_name": "Edit", + "tool_input": { + "file_path": "main.ts" + }, + "tool_response": { + "structuredPatch": { + "hunks": [ + { + "oldStart": 1, + "oldCount": 5, + "newStart": 1, + "newCount": 3, + "lines": [ + { + "kind": "context", + "content": "import { foo } from \"./foo\";" + }, + { + "kind": "context", + "content": "import { bar } from \"./bar\";" + }, + { + "kind": "context", + "content": "" + }, + { + "kind": "removed", + "content": "import { baz } from \"./baz\";" + }, + { + "kind": "removed", + "content": "import { qux } from \"./qux\";" + } + ] + } + ] + } + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch new file mode 100644 index 00000000..9c8faeb7 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch @@ -0,0 +1,10 @@ +Index: main.ts +=================================================================== +--- a/main.ts ++++ b/main.ts +@@ -1,5 +1,3 @@ + import { foo } from "./foo"; + import { bar } from "./bar"; + +-import { baz } from "./baz"; +-import { qux } from "./qux"; diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json new file mode 100644 index 00000000..174de2d1 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json @@ -0,0 +1,6 @@ +{ + "filePath": "main.ts", + "renderedHunks": [ + "@@ -1,5 +1,3 @@\n import { foo } from \"./foo\";\n import { bar } from \"./bar\";\n \n-import { baz } from \"./baz\";\n-import { qux } from \"./qux\";" + ] +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json new file mode 100644 index 00000000..3f51a975 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json @@ -0,0 +1,31 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-edit-single-hunk", + "tool_name": "Edit", + "tool_input": { + "file_path": "poem.md" + }, + "tool_response": { + "structuredPatch": { + "hunks": [ + { + "oldStart": 1, + "oldCount": 8, + "newStart": 1, + "newCount": 8, + "lines": [ + " # Q Moment of Code", + " ", + " In circuits deep where logic flows,", + "-A thousand silent processes glow,", + "+Q thousand silent processes glow,", + " Through paths of thought, both near and far,", + " We build the world the way things are.", + " ", + " Each line we write, each function named," + ] + } + ] + } + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch new file mode 100644 index 00000000..2672bfc1 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch @@ -0,0 +1,14 @@ +Index: poem.md +=================================================================== +--- a/poem.md ++++ b/poem.md +@@ -1,8 +1,8 @@ + # Q Moment of Code + + In circuits deep where logic flows, +-A thousand silent processes glow, ++Q thousand silent processes glow, + Through paths of thought, both near and far, + We build the world the way things are. + + Each line we write, each function named, diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json new file mode 100644 index 00000000..b300d6d5 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json @@ -0,0 +1,6 @@ +{ + "filePath": "poem.md", + "renderedHunks": [ + "@@ -1,8 +1,8 @@\n # Q Moment of Code\n \n In circuits deep where logic flows,\n -A thousand silent processes glow,\n +Q thousand silent processes glow,\n Through paths of thought, both near and far,\n We build the world the way things are.\n \n Each line we write, each function named," + ] +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json new file mode 100644 index 00000000..be46a9e0 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json @@ -0,0 +1,12 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-write-create-empty", + "tool_name": "Write", + "tool_input": { + "file_path": "src/empty.md", + "content": "" + }, + "tool_response": { + "originalFile": null + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch new file mode 100644 index 00000000..9129bff3 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch @@ -0,0 +1,4 @@ +diff --git a/src/empty.md b/src/empty.md +new file mode 100644 +--- /dev/null ++++ b/src/empty.md diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json new file mode 100644 index 00000000..a2165f79 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json @@ -0,0 +1,4 @@ +{ + "filePath": "src/empty.md", + "content": "" +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json new file mode 100644 index 00000000..b4e904fb --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json @@ -0,0 +1,12 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-write-create-multiline", + "tool_name": "Write", + "tool_input": { + "file_path": "poem.md", + "content": "# A Moment of Code\n\nIn circuits deep where logic flows,\nA thousand silent processes glow,\nThrough paths of thought, both near and far,\nWe build the world the way things are.\n\nEach line we write, each function named,\nA story told, a problem tamed,\nThe compiler hums, the tests turn green,\nIn spaces where the code is clean.\n\nWe dream in loops of endless while,\nYet find in bugs a reason to smile,\nForth every error teaches true,\nThe better paths to journey through.\n\nSo let us craft with care and art,\nThe systems that connect each heart,\nAnd in this dance of bit and byte,\nWe shape tomorrow's morning light.\n\n---\n*Written with ❤️ and a touch of caffeine*\n" + }, + "tool_response": { + "originalFile": null + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch new file mode 100644 index 00000000..8725a568 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch @@ -0,0 +1,29 @@ +diff --git a/poem.md b/poem.md +new file mode 100644 +--- /dev/null ++++ b/poem.md +@@ -0,0 +1,24 @@ ++# A Moment of Code ++ ++In circuits deep where logic flows, ++A thousand silent processes glow, ++Through paths of thought, both near and far, ++We build the world the way things are. ++ ++Each line we write, each function named, ++A story told, a problem tamed, ++The compiler hums, the tests turn green, ++In spaces where the code is clean. ++ ++We dream in loops of endless while, ++Yet find in bugs a reason to smile, ++Forth every error teaches true, ++The better paths to journey through. ++ ++So let us craft with care and art, ++The systems that connect each heart, ++And in this dance of bit and byte, ++We shape tomorrow's morning light. ++ ++--- ++*Written with ❤️ and a touch of caffeine* diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json new file mode 100644 index 00000000..801ce642 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json @@ -0,0 +1,4 @@ +{ + "filePath": "poem.md", + "content": "# A Moment of Code\n\nIn circuits deep where logic flows,\nA thousand silent processes glow,\nThrough paths of thought, both near and far,\nWe build the world the way things are.\n\nEach line we write, each function named,\nA story told, a problem tamed,\nThe compiler hums, the tests turn green,\nIn spaces where the code is clean.\n\nWe dream in loops of endless while,\nYet find in bugs a reason to smile,\nForth every error teaches true,\nThe better paths to journey through.\n\nSo let us craft with care and art,\nThe systems that connect each heart,\nAnd in this dance of bit and byte,\nWe shape tomorrow's morning light.\n\n---\n*Written with ❤️ and a touch of caffeine*\n" +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json new file mode 100644 index 00000000..6766dc3c --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json @@ -0,0 +1,12 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-write-create-no-newline", + "tool_name": "Write", + "tool_input": { + "file_path": "src/version.txt", + "content": "1.0.0" + }, + "tool_response": { + "originalFile": null + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch new file mode 100644 index 00000000..8cdef9a7 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch @@ -0,0 +1,6 @@ +diff --git a/src/version.txt b/src/version.txt +new file mode 100644 +--- /dev/null ++++ b/src/version.txt +@@ -0,0 +1,1 @@ ++1.0.0 diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json new file mode 100644 index 00000000..cad7d448 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json @@ -0,0 +1,4 @@ +{ + "filePath": "src/version.txt", + "content": "1.0.0" +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json new file mode 100644 index 00000000..d75b4dea --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json @@ -0,0 +1,12 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "test-write-create-simple", + "tool_name": "Write", + "tool_input": { + "file_path": "hunks/hello.ts", + "content": "function helloWorld(): void {\n console.log(\"Hello World\");\n}\n\nhelloWorld();\n" + }, + "tool_response": { + "originalFile": null + } +} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch new file mode 100644 index 00000000..496ddbf7 --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch @@ -0,0 +1,10 @@ +diff --git a/hunks/hello.ts b/hunks/hello.ts +new file mode 100644 +--- /dev/null ++++ b/hunks/hello.ts +@@ -0,0 +1,5 @@ ++function helloWorld(): void { ++ console.log("Hello World"); ++} ++ ++helloWorld(); diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json new file mode 100644 index 00000000..e1e5fb5a --- /dev/null +++ b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json @@ -0,0 +1,4 @@ +{ + "filePath": "hunks/hello.ts", + "content": "function helloWorld(): void {\n console.log(\"Hello World\");\n}\n\nhelloWorld();\n" +} diff --git a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts new file mode 100644 index 00000000..f04448b5 --- /dev/null +++ b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { deriveClaudeDiffTracePayload } from "./claude-sce-agent-trace-plugin.ts"; + +const FIXED_TIME = 1700000000000; +const FIXED_TOOL_VERSION = "test-claude-version"; +const EXPECTED_SCENARIOS = [ + "write_create_simple", + "write_create_empty", + "write_create_no_newline", + "write_create_multiline", + "edit_single_hunk", + "edit_multi_hunk", + "edit_only_additions", + "edit_only_deletions", +] as const; + +type ClaudePostToolUseFixture = { + session_id: string; +}; + +const fixtureRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../cli/src/services/patch/fixtures/diff_creation", +); + +function discoverFixtureScenarios(): string[] { + return readdirSync(fixtureRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function orderedFixtureScenarios(): string[] { + const discovered = discoverFixtureScenarios(); + const discoveredSet = new Set(discovered); + const expectedSet = new Set(EXPECTED_SCENARIOS); + const missing = EXPECTED_SCENARIOS.filter((name) => !discoveredSet.has(name)); + const extra = discovered.filter((name) => !expectedSet.has(name)); + + if (missing.length > 0 || extra.length > 0) { + throw new Error( + `Unexpected Claude diff-creation fixtures. Missing: ${missing.join(", ") || "none"}. Extra: ${extra.join(", ") || "none"}.`, + ); + } + + return EXPECTED_SCENARIOS.filter((name) => discoveredSet.has(name)); +} + +function loadFixture(name: string): { + input: ClaudePostToolUseFixture; + expected: string; +} { + const dir = path.join(fixtureRoot, name); + const input = JSON.parse( + readFileSync(path.join(dir, "claude-post-tool-use.json"), "utf-8"), + ) as unknown; + const expected = readFileSync(path.join(dir, "expected.patch"), "utf-8"); + + if (!hasSessionId(input)) { + throw new Error(`${name} fixture is missing a string session_id`); + } + + return { input, expected }; +} + +function hasSessionId(value: unknown): value is ClaudePostToolUseFixture { + return ( + typeof value === "object" && + value !== null && + "session_id" in value && + typeof value.session_id === "string" + ); +} + +describe("deriveClaudeDiffTracePayload", () => { + for (const name of orderedFixtureScenarios()) { + test(`claude_derivation/${name}`, () => { + const { input, expected } = loadFixture(name); + const result = deriveClaudeDiffTracePayload({ + eventName: "PostToolUse", + payload: input, + now: () => FIXED_TIME, + toolVersion: FIXED_TOOL_VERSION, + }); + + expect(result.status).toBe("derived"); + if (result.status !== "derived") { + throw new Error(`Expected ${name} fixture to derive a diff trace`); + } + + expect(result.payload.sessionID).toBe(input.session_id); + expect(result.payload.time).toBe(FIXED_TIME); + expect(result.payload.tool_name).toBe("claude"); + expect(result.payload.tool_version).toBe(FIXED_TOOL_VERSION); + expect(result.payload.diff).toBe(expected); + expect(Object.hasOwn(result.payload, "model_id")).toBe(false); + }); + } +}); diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index 7714d532..150af25e 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -53,7 +53,7 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd - **Result structure**: only files with at least one overlapping touched line appear in the result; hunks with no overlapping lines are excluded; hunk range metadata (`old_start`, `old_count`, `new_start`, `new_count`) is preserved from the second patch (`b`) so the result keeps the target patch shape, while hunk `model_id` provenance is inherited from the matched hunk in the first patch (`a`) when available (and remains `None` when matched constructed provenance is absent) - **Determinism**: the same inputs always produce the same output - **Equivalent-hunk behavior**: semantically identical hunks still intersect when they differ only in surrounding context windows, hunk header ranges, or absolute-vs-relative `Index:` path spelling, as long as their touched-line identities match exactly -- **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches and then intersects with the current commit patch (see `agent-trace-hooks-command-routing.md`). Previously listed as "not yet wired" before T04. +- **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches and then intersects with the current commit patch (see `agent-trace-hooks-command-routing.md`). ### Combination @@ -64,7 +64,7 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd - **Hunk reconstruction and provenance**: surviving lines are grouped by their hunk metadata from the last contributing patch; each reconstructed hunk preserves that winning hunk's `model_id` provenance; hunks are ordered by `old_start`; lines within each hunk are ordered by `line_number` with `Removed` before `Added` at the same position, then by `content` for full determinism - **File ordering**: files appear in the result in the order they are first encountered across the input patches - **Determinism**: the same inputs in the same order always produce the same output -- **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches before intersecting (see `agent-trace-hooks-command-routing.md`). Previously listed as "not yet wired" before T04. +- **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches before intersecting (see `agent-trace-hooks-command-routing.md`). ### Runtime wiring status @@ -82,6 +82,11 @@ Public types consumed by the parser or load helpers have `#[allow(dead_code)]` r Patch reconstruction tests use deterministic fixture suites under `cli/src/services/patch/fixtures/`. - Existing suites remain intact (`average_age_reconstruction`, `hello_world_reconstruction`). +- `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by TypeScript derivation coverage. Each scenario directory has: + - `input.json` describing the canonical file path/content or rendered hunk inputs + - `expected.patch` containing the expected unified diff output + - `claude-post-tool-use.json` containing a sanitized synthetic Claude `PostToolUse` payload shaped for `deriveClaudeDiffTracePayload(...)` + - Covered scenarios: simple/empty/no-newline/multiline Write create flows and single-hunk/multi-hunk/additions-only/deletions-only Edit flows - The current tmp-hunks scenario is materialized as `text_file_lifecycle_reconstruction/` with: - `incremental_01.patch` .. `incremental_26.patch` reconstructed from `tmp_hunks/*-message.part.updated.json` in lexical filename order - `post_commit.patch` reconstructed from `tmp_hunks/*-post-commit.json` `input.head_patch_from_git` diff --git a/context/context-map.md b/context/context-map.md index b9f82944..974b4076 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -12,7 +12,7 @@ Feature/domain context: - `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow, WorkOS device authorization flow + token storage behavior, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + Agent Trace DB bootstrap plus doctor DB health coverage, nested flake release package/app installability, and Cargo local install + crates.io readiness policy; `sce sync` command wiring is deferred to `0.4.0`; migrated runtime command structs for help/version/completion/auth/config/setup/doctor/hooks are owned by their respective `services/{name}/command.rs` files, while clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) -- `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json`/`load_patch_from_json_bytes` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) +- `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, set operations, and checked-in reconstruction/diff-creation fixture suites for `cli/src/services/patch.rs`; covers touched-line parsing for `Index:` SVN-style and `diff --git` git-style formats, `ParseError`/`PatchLoadError` diagnostics, `intersect_patches` target-shaped overlap with `model_id` provenance inheritance, `combine_patches` later-wins conflict resolution, active post-commit hook consumption, and the Claude derivation `diff_creation/` golden fixtures) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) @@ -49,12 +49,12 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, and always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation as nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`model_id`/`tool_name`, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and `session-model` STDIN intake for normalized model attribution upsert without raw artifact persistence; the removed `claude-capture` route was deleted in T05) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` resolved from `session_models` when absent, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and `session-model` STDIN intake for normalized model attribution upsert without raw artifact persistence; the removed `claude-capture` route was deleted in T05) - `context/sce/claude-raw-hook-capture.md` (removed feature — `claude-capture` CLI route, `ClaudeCaptureEvent`, `claude_transcript.rs`, and `RepoPaths::claude_capture_tmp_dir()` were deleted in T05. Rust now exposes only normalized `session-model` and `diff-trace` intakes.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus the implementation target for generated OpenCode enforcement, including config schema, argv-prefix matching, fixed preset catalog/messages, and precedence rules) - `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and the boundary that Claude uses generated `.claude/settings.json` + Bun runtime registration instead of OpenCode plugin manifests; Claude bash-policy enforcement has been removed from generated outputs) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`, and the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary; Rust hook parsing and AgentTraceDb insertion persist required payload fields including `model_id`; `session.diff` event capture has been removed) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode and Claude TypeScript agent-trace runtime behavior, including OpenCode `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name` plus required nullable `tool_version`, the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary, Claude `PostToolUse` Write/Edit derivation through `deriveClaudeDiffTracePayload(...)`, and derivation golden tests over `cli/src/services/patch/fixtures/diff_creation/`; Rust hook parsing and AgentTraceDb insertion persist parsed payload fields including optional/resolved `model_id`; `session.diff` event capture has been removed) - `context/sce/cli-first-install-channels-contract.md` (current first-wave `sce` install/distribution contract covering supported channels, canonical naming, `.version` release authority, and Nix-owned build policy) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, and the explicit non-default execution boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, and the current three-target Linux/macOS release workflow topology) diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index e14bea3e..c53b8924 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -1,6 +1,9 @@ # OpenCode agent-trace plugin runtime -Current runtime source: `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts`. +Current TypeScript runtime sources: + +- `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` +- `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts` ## Event capture baseline @@ -41,3 +44,10 @@ Otherwise, the helper returns `undefined`. - OpenCode registration remains the generated OpenCode `opencode.json` plugin manifest; Claude registration remains generated `.claude/settings.json` command hooks that run Bun against `.claude/plugins/sce-agent-trace.ts`. - The shared Rust boundary is `sce hooks diff-trace`: both runtimes send `{ sessionID, diff, time, model_id, tool_name, tool_version }` over STDIN JSON, and Rust remains the only writer of parsed `context/tmp/*-diff-trace.json` artifacts and AgentTraceDb `diff_traces` rows. - Claude `model_id` differs from OpenCode attribution: OpenCode reads provider/model data from the OpenCode event, while Claude resolves `model_id` from AgentTraceDb `session_models` at Rust persistence time and skips `diff-trace` persistence when no matching session model row exists. + +## Claude derivation golden tests + +- `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts` tests the exported `deriveClaudeDiffTracePayload(...)` seam only; private diff-render helpers remain unexported and untested directly. +- The test dynamically discovers the checked-in `cli/src/services/patch/fixtures/diff_creation/` scenario directories, validates the expected eight-scenario set, then loads each `claude-post-tool-use.json` plus `expected.patch` pair. +- Each scenario calls `deriveClaudeDiffTracePayload(...)` with fixed time/tool-version inputs and asserts derived status, session ID, time, `tool_name="claude"`, tool version, exact golden diff, and no emitted `model_id`. +- The tests are discoverable from both `config/lib/agent-trace-plugin` and the shared `config/lib` Bun package root via `bun test`. From 01a63599c0d5a4a269c6ab741ebe3ad70695bb26 Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Tue, 9 Jun 2026 15:35:10 +0200 Subject: [PATCH 06/18] patch: Remove unused diff_creation input fixtures Delete unused input.json sidecars from the Claude diff-creation fixture suite. The active golden tests consume claude-post-tool-use.json and expected.patch, so the patch-service context now documents that fixture contract directly. Co-authored-by: SCE --- .../fixtures/diff_creation/edit_multi_hunk/input.json | 7 ------- .../fixtures/diff_creation/edit_only_additions/input.json | 6 ------ .../fixtures/diff_creation/edit_only_deletions/input.json | 6 ------ .../fixtures/diff_creation/edit_single_hunk/input.json | 6 ------ .../fixtures/diff_creation/write_create_empty/input.json | 4 ---- .../diff_creation/write_create_multiline/input.json | 4 ---- .../diff_creation/write_create_no_newline/input.json | 4 ---- .../fixtures/diff_creation/write_create_simple/input.json | 4 ---- context/cli/patch-service.md | 6 +++--- 9 files changed, 3 insertions(+), 44 deletions(-) delete mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json delete mode 100644 cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json deleted file mode 100644 index 00ef780b..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/input.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "filePath": "poem.md", - "renderedHunks": [ - "@@ -1,8 +1,8 @@\n # Q Moment of Code\n \n In circuits deep where logic flows,\n -A thousand silent processes glow,\n +Q thousand silent processes glow,\n Through paths of thought, both near and far,\n We build the world the way things are.\n \n Each line we write, each function named,", - "@@ -20,5 +20,5 @@\n And in this dance of bit and byte,\n We shape tomorrow's morning light.\n \n ---\n -*Written with ❤️ and a touch of caffeine*\n +*Written with ❤️ and a touch of caffeinw*" - ] -} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json deleted file mode 100644 index b6fb6fbe..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/input.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "filePath": "main.ts", - "renderedHunks": [ - "@@ -1,3 +1,5 @@\n import { foo } from \"./foo\";\n import { bar } from \"./bar\";\n \n+import { baz } from \"./baz\";\n+import { qux } from \"./qux\";" - ] -} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json deleted file mode 100644 index 174de2d1..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/input.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "filePath": "main.ts", - "renderedHunks": [ - "@@ -1,5 +1,3 @@\n import { foo } from \"./foo\";\n import { bar } from \"./bar\";\n \n-import { baz } from \"./baz\";\n-import { qux } from \"./qux\";" - ] -} diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json b/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json deleted file mode 100644 index b300d6d5..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/input.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "filePath": "poem.md", - "renderedHunks": [ - "@@ -1,8 +1,8 @@\n # Q Moment of Code\n \n In circuits deep where logic flows,\n -A thousand silent processes glow,\n +Q thousand silent processes glow,\n Through paths of thought, both near and far,\n We build the world the way things are.\n \n Each line we write, each function named," - ] -} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json deleted file mode 100644 index a2165f79..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/input.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "filePath": "src/empty.md", - "content": "" -} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json deleted file mode 100644 index 801ce642..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/input.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "filePath": "poem.md", - "content": "# A Moment of Code\n\nIn circuits deep where logic flows,\nA thousand silent processes glow,\nThrough paths of thought, both near and far,\nWe build the world the way things are.\n\nEach line we write, each function named,\nA story told, a problem tamed,\nThe compiler hums, the tests turn green,\nIn spaces where the code is clean.\n\nWe dream in loops of endless while,\nYet find in bugs a reason to smile,\nForth every error teaches true,\nThe better paths to journey through.\n\nSo let us craft with care and art,\nThe systems that connect each heart,\nAnd in this dance of bit and byte,\nWe shape tomorrow's morning light.\n\n---\n*Written with ❤️ and a touch of caffeine*\n" -} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json deleted file mode 100644 index cad7d448..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/input.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "filePath": "src/version.txt", - "content": "1.0.0" -} diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json b/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json deleted file mode 100644 index e1e5fb5a..00000000 --- a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/input.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "filePath": "hunks/hello.ts", - "content": "function helloWorld(): void {\n console.log(\"Hello World\");\n}\n\nhelloWorld();\n" -} diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index 150af25e..b344e0d2 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -82,10 +82,10 @@ Public types consumed by the parser or load helpers have `#[allow(dead_code)]` r Patch reconstruction tests use deterministic fixture suites under `cli/src/services/patch/fixtures/`. - Existing suites remain intact (`average_age_reconstruction`, `hello_world_reconstruction`). -- `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by TypeScript derivation coverage. Each scenario directory has: - - `input.json` describing the canonical file path/content or rendered hunk inputs - - `expected.patch` containing the expected unified diff output +- `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by TypeScript derivation coverage. Each active golden scenario consumes: - `claude-post-tool-use.json` containing a sanitized synthetic Claude `PostToolUse` payload shaped for `deriveClaudeDiffTracePayload(...)` + - `expected.patch` containing the expected unified diff output + - No `input.json` sidecar is part of the active fixture contract for this suite. - Covered scenarios: simple/empty/no-newline/multiline Write create flows and single-hunk/multi-hunk/additions-only/deletions-only Edit flows - The current tmp-hunks scenario is materialized as `text_file_lifecycle_reconstruction/` with: - `incremental_01.patch` .. `incremental_26.patch` reconstructed from `tmp_hunks/*-message.part.updated.json` in lexical filename order From d9b972b40394cc3a47320192d2b7a59b62a2ac1e Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Wed, 10 Jun 2026 11:00:10 +0200 Subject: [PATCH 07/18] hooks: Unify STDIN payload validation helpers Share payload-kind-aware validation helpers across diff-trace and session-model hook payload parsing while preserving each command's user-facing error prefix. Co-authored-by: SCE --- cli/src/services/hooks/mod.rs | 199 +++++++++++----------------------- 1 file changed, 62 insertions(+), 137 deletions(-) diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 7fd975a5..b8e2927c 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -71,6 +71,25 @@ struct DiffTracePayload { tool_version: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StdinPayloadKind { + DiffTrace, + SessionModel, +} + +impl StdinPayloadKind { + fn label(self) -> &'static str { + match self { + Self::DiffTrace => "diff-trace", + Self::SessionModel => "session-model", + } + } + + fn validation_error(self, detail: &str) -> String { + format!("Invalid {} payload from STDIN: {detail}.", self.label()) + } +} + /// Required `sce hooks diff-trace` STDIN payload shape: /// `{ sessionID, diff, time, model_id?, tool_name, tool_version }`. /// @@ -221,7 +240,7 @@ fn run_session_model_subcommand_from_payload( // Convert the u64 time to i64 for DB storage. let session_start_time_ms = i64::try_from(payload.time).map_err(|_| { - anyhow!(sm_err( + anyhow!(StdinPayloadKind::SessionModel.validation_error( "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" )) })?; @@ -260,18 +279,20 @@ fn run_session_model_subcommand_from_payload( } fn parse_diff_trace_payload(stdin_payload: &str) -> Result { + let payload_kind = StdinPayloadKind::DiffTrace; let parsed: Value = serde_json::from_str(stdin_payload) - .context("Invalid diff-trace payload from STDIN: expected valid JSON.")?; + .with_context(|| payload_kind.validation_error("expected valid JSON"))?; let payload = parsed .as_object() - .ok_or_else(|| anyhow!(diff_trace_validation_error("expected a JSON object")))?; + .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; - let session_id = required_non_empty_string_field(payload, "sessionID")?; - let diff = required_non_empty_string_field(payload, "diff")?; - let time = required_u64_millisecond_field(payload, "time")?; - let model_id = optional_string_field(payload, "model_id")?; - let tool_name = required_non_empty_string_field(payload, "tool_name")?; - let tool_version = required_nullable_or_non_empty_string_field(payload, "tool_version")?; + let session_id = required_non_empty_string_field(payload, "sessionID", payload_kind)?; + let diff = required_non_empty_string_field(payload, "diff", payload_kind)?; + let time = required_u64_millisecond_field(payload, "time", payload_kind)?; + let model_id = optional_string_field(payload, "model_id", payload_kind)?; + let tool_name = required_non_empty_string_field(payload, "tool_name", payload_kind)?; + let tool_version = + required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; Ok(DiffTracePayload { session_id, @@ -284,17 +305,19 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result { } fn parse_session_model_payload(stdin_payload: &str) -> Result { + let payload_kind = StdinPayloadKind::SessionModel; let parsed: Value = serde_json::from_str(stdin_payload) - .context("Invalid session-model payload from STDIN: expected valid JSON.")?; + .with_context(|| payload_kind.validation_error("expected valid JSON"))?; let payload = parsed .as_object() - .ok_or_else(|| anyhow!(sm_err("expected a JSON object")))?; + .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; - let session_id = sm_non_empty(payload, "sessionID")?; - let time = sm_u64(payload, "time")?; - let model_id = sm_non_empty(payload, "model_id")?; - let tool_name = sm_non_empty(payload, "tool_name")?; - let tool_version = sm_nullable_or_non_empty(payload, "tool_version")?; + let session_id = required_non_empty_string_field(payload, "sessionID", payload_kind)?; + let time = required_u64_millisecond_field(payload, "time", payload_kind)?; + let model_id = required_non_empty_string_field(payload, "model_id", payload_kind)?; + let tool_name = required_non_empty_string_field(payload, "tool_name", payload_kind)?; + let tool_version = + required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; Ok(SessionModelPayload { session_id, @@ -305,119 +328,25 @@ fn parse_session_model_payload(stdin_payload: &str) -> Result String { - format!("Invalid session-model payload from STDIN: {detail}.") -} - -fn sm_non_empty(payload: &serde_json::Map, field: &str) -> Result { - let raw = payload - .get(field) - .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; - let value = raw.as_str().ok_or_else(|| { - anyhow!(sm_err(&format!( - "field '{field}' must be a non-empty string" - ))) - })?; - if value.trim().is_empty() { - bail!(sm_err(&format!( - "field '{field}' must be a non-empty string" - ))); - } - Ok(value.to_string()) -} - -#[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss -)] -fn sm_u64(payload: &serde_json::Map, field: &str) -> Result { - let raw = payload - .get(field) - .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; - - if let Some(value) = raw.as_u64() { - return Ok(value); - } - - if let Some(value) = raw.as_i64() { - if value < 0 { - bail!(sm_err(&format!( - "field '{field}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - return Ok(value as u64); - } - - if let Some(value) = raw.as_f64() { - if value.fract() != 0.0 { - bail!(sm_err(&format!( - "field '{field}' must be a u64 Unix epoch millisecond value, got a fractional number" - ))); - } - if value < 0.0 { - bail!(sm_err(&format!( - "field '{field}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - if value > u64::MAX as f64 { - bail!(sm_err(&format!( - "field '{field}' must be a u64 Unix epoch millisecond value" - ))); - } - return Ok(value as u64); - } - - bail!(sm_err(&format!( - "field '{field}' must be a u64 Unix epoch millisecond value" - ))) -} - -fn sm_nullable_or_non_empty( - payload: &serde_json::Map, - field: &str, -) -> Result> { - let raw = payload - .get(field) - .ok_or_else(|| anyhow!(sm_err(&format!("missing required field '{field}'"))))?; - - if raw.is_null() { - return Ok(None); - } - - let value = raw.as_str().ok_or_else(|| { - anyhow!(sm_err(&format!( - "field '{field}' must be null or a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(sm_err(&format!( - "field '{field}' must be null or a non-empty string" - ))); - } - - Ok(Some(value.to_string())) -} - fn required_nullable_or_non_empty_string_field( payload: &serde_json::Map, field_name: &str, + payload_kind: StdinPayloadKind, ) -> Result> { - let raw = required_field(payload, field_name)?; + let raw = required_field(payload, field_name, payload_kind)?; if raw.is_null() { return Ok(None); } let value = raw.as_str().ok_or_else(|| { - anyhow!(diff_trace_validation_error(&format!( + anyhow!(payload_kind.validation_error(&format!( "field '{field_name}' must be null or a non-empty string" ))) })?; if value.trim().is_empty() { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be null or a non-empty string" ))); } @@ -428,6 +357,7 @@ fn required_nullable_or_non_empty_string_field( fn optional_string_field( payload: &serde_json::Map, field_name: &str, + payload_kind: StdinPayloadKind, ) -> Result> { let Some(raw) = payload.get(field_name) else { return Ok(None); @@ -438,13 +368,13 @@ fn optional_string_field( } let value = raw.as_str().ok_or_else(|| { - anyhow!(diff_trace_validation_error(&format!( + anyhow!(payload_kind.validation_error(&format!( "field '{field_name}' must be null, absent, or a non-empty string" ))) })?; if value.trim().is_empty() { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be null, absent, or a non-empty string" ))); } @@ -455,19 +385,18 @@ fn optional_string_field( fn required_non_empty_string_field( payload: &serde_json::Map, field_name: &str, + payload_kind: StdinPayloadKind, ) -> Result { - let raw = required_field(payload, field_name)?; + let raw = required_field(payload, field_name, payload_kind)?; let value = raw.as_str().ok_or_else(|| { - anyhow!(diff_trace_validation_error(&format!( - "field '{field_name}' must be a non-empty string" - ))) + anyhow!(payload_kind + .validation_error(&format!("field '{field_name}' must be a non-empty string"))) })?; if value.trim().is_empty() { - bail!(diff_trace_validation_error(&format!( - "field '{field_name}' must be a non-empty string" - ))); + bail!(payload_kind + .validation_error(&format!("field '{field_name}' must be a non-empty string"))); } Ok(value.to_string()) @@ -481,8 +410,9 @@ fn required_non_empty_string_field( fn required_u64_millisecond_field( payload: &serde_json::Map, field_name: &str, + payload_kind: StdinPayloadKind, ) -> Result { - let raw = required_field(payload, field_name)?; + let raw = required_field(payload, field_name, payload_kind)?; if let Some(value) = raw.as_u64() { return Ok(value); @@ -490,7 +420,7 @@ fn required_u64_millisecond_field( if let Some(value) = raw.as_i64() { if value < 0 { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" ))); } @@ -499,24 +429,24 @@ fn required_u64_millisecond_field( if let Some(value) = raw.as_f64() { if value.fract() != 0.0 { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be a u64 Unix epoch millisecond value, got a fractional number" ))); } if value < 0.0 { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" ))); } if value > u64::MAX as f64 { - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be a u64 Unix epoch millisecond value" ))); } return Ok(value as u64); } - bail!(diff_trace_validation_error(&format!( + bail!(payload_kind.validation_error(&format!( "field '{field_name}' must be a u64 Unix epoch millisecond value" ))) } @@ -524,18 +454,13 @@ fn required_u64_millisecond_field( fn required_field<'a>( payload: &'a serde_json::Map, field_name: &str, + payload_kind: StdinPayloadKind, ) -> Result<&'a Value> { payload.get(field_name).ok_or_else(|| { - anyhow!(diff_trace_validation_error(&format!( - "missing required field '{field_name}'" - ))) + anyhow!(payload_kind.validation_error(&format!("missing required field '{field_name}'"))) }) } -fn diff_trace_validation_error(detail: &str) -> String { - format!("Invalid diff-trace payload from STDIN: {detail}.") -} - fn persist_diff_trace_payload( repository_root: &Path, payload: &DiffTracePayload, @@ -591,7 +516,7 @@ where fn diff_trace_db_time_ms(time: u64) -> Result { i64::try_from(time).map_err(|_| { - anyhow!(diff_trace_validation_error( + anyhow!(StdinPayloadKind::DiffTrace.validation_error( "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" )) }) From 5b8cbfba7a96c908f40b91fc85fc51e27e7c15ed Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Wed, 10 Jun 2026 11:01:03 +0200 Subject: [PATCH 08/18] flake: Preserve repo-shaped config-lib check source Run config-lib Bun and Biome checks from config/lib inside a copied repo-shaped source tree, including the shared CLI diff_creation fixtures needed by agent-trace golden tests. Records the source-layout contract for config-lib checks. Co-authored-by: SCE --- context/architecture.md | 2 ++ context/glossary.md | 1 + context/overview.md | 1 + context/patterns.md | 1 + flake.nix | 21 +++++++++++---------- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/context/architecture.md b/context/architecture.md index 9e420001..1ac0a79a 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -37,6 +37,7 @@ Current target renderer helper modules: - `config/pkl/generate.pkl` (single multi-file generation entrypoint) - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) - `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,pkl-parity,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, generated-output parity, and JS validation inventory) +- `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/patch/fixtures/diff_creation` for Claude agent-trace golden fixture coverage. The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated Claude plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). @@ -123,6 +124,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, version, completion, help, patch, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` trait, `RuntimeCommandHandle` type alias, `CommandRegistry` struct, and `build_default_registry()` function for the command dispatch registry. Service-owned command modules now own the migrated runtime command structs and `RuntimeCommand` impls for help/help-text, version, completion, auth, config, setup, doctor, and hooks. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs Bun-discovered tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. +- The config-lib check source preserves repo-relative access to shared CLI patch fixtures: Nix copies a filtered repo-shaped source containing `config/lib/**` plus `cli/src/services/patch/fixtures/diff_creation`, then runs Bun/Biome from `config/lib/`. - `flake.nix` exposes release install/run surfaces as `packages.sce` (`packages.default = packages.sce`) plus `apps.sce` and `apps.default`, all targeting `${packages.sce}/bin/sce`; this keeps repo-local and remote flake run/install flows (`nix run .`, `nix run github:crocoder-dev/shared-context-engineering`, `nix profile install github:crocoder-dev/shared-context-engineering`) aligned to the same packaged CLI output. - `biome.json` at the repository root is the canonical Biome configuration for the current JS tooling slice and deliberately scopes coverage to `npm/**` plus the shared `config/lib/**` plugin package root while excluding package-local `node_modules/**`; `flake.nix` exposes Biome through the default dev shell rather than through package-local installs. - `cli/Cargo.toml` now keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`), git (`cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`), and local checkout (`cargo install --path cli --locked`) guidance. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. diff --git a/context/glossary.md b/context/glossary.md index 511049ce..c006baf6 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -13,6 +13,7 @@ - `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split shared `config/lib/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. - `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs Bun-discovered tests from the copied shared `config/lib/` package source (including bash-policy runtime tests and tracked agent-trace plugin tests), with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. +- `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/patch/fixtures/diff_creation` for Claude agent-trace golden tests. - `config-lib shared package root`: Shared Bun/TypeScript package root at `config/lib/` for repository-owned OpenCode plugin support code. It owns `package.json`, `bun.lock`, and `tsconfig.json`, pins `@opencode-ai/plugin` to `1.15.4`, includes both `agent-trace-plugin/**/*.ts` and `bash-policy-plugin/**/*.ts` in strict TypeScript coverage, and excludes package-local `node_modules/` from both TypeScript and root Biome coverage. - `cli rust overlay toolchain`: Toolchain contract in root `flake.nix` that applies `rust-overlay.overlays.default`, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, uses that toolchain across both Crane package and check derivations, and keeps toolchain selection explicit rather than inheriting nixpkgs defaults. - `cli Crane package pipeline`: Current root-flake packaging path in `flake.nix` where `packages.sce` is built through `craneLib.buildDepsOnly` plus `craneLib.buildPackage` against a filtered repo-root source that preserves the Cargo tree and the embedded config/assets required by `cli/build.rs`. diff --git a/context/overview.md b/context/overview.md index b5bb1809..12c45468 100644 --- a/context/overview.md +++ b/context/overview.md @@ -31,6 +31,7 @@ The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in `cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`, `cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`, and local `cargo install --path cli --locked`. The published crate installs the `sce` binary. The crate also keeps `cargo clippy --manifest-path cli/Cargo.toml` warnings-denied through `cli/Cargo.toml` lint configuration, so an extra `-- -D warnings` flag is redundant. The repository-root flake is now the single Nix entrypoint for both repo tooling and CLI packaging/checks, so root-level `nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus six split JavaScript check derivations: `npm-bun-tests`, `npm-biome-check`, `npm-biome-format`, `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format`, without nested-flake indirection. The config-lib checks now consume `config/lib/` as the shared Bun/TypeScript package root for both `agent-trace-plugin/` and `bash-policy-plugin/`, with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`. For Cargo packaging/builds, the crate now compiles against a temporary `cli/assets/generated/` mirror prepared from canonical `config/` outputs during Nix builds and crates.io publish runs rather than from a committed crate-local snapshot. +Config-lib JS flake checks execute from `config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests consume `cli/src/services/patch/fixtures/diff_creation` through their normal repo-relative path. Local developer Nix tuning guidance now lives in `AGENTS.md`, including optional user-level `~/.config/nix/nix.conf` recommendations for `max-jobs` and `cores` plus an explicit system-level-only note for `auto-optimise-store`. The Pkl authoring layer owns generated OpenCode plugin registration for SCE-managed plugins: `config/pkl/base/opencode.pkl` defines the canonical plugin entries, `config/pkl/renderers/common.pkl` re-exports the shared plugin list for renderer use, and generated `config/.opencode/opencode.json` plus `config/automated/.opencode/opencode.json` register `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts` through OpenCode's `plugin` field. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. The current first-wave CLI install/distribution contract is now defined for `sce`: the active implemented channel set is repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the current implementation stage. Nix-managed build/release entrypoints are the source of truth for this rollout, npm consumes Nix-produced release artifacts, and repo-root `.version` is the canonical checked-in release version source that release packaging and downstream Cargo/npm publication must match. The shared release artifact foundation is now implemented through root-flake apps `release-artifacts` and `release-manifest`, which emit canonical `sce-v-.tar.gz` archives, SHA-256 checksum files, merged manifest outputs, and a detached `sce-v-release-manifest.json.sig` produced from a non-repo private signing key; the npm distribution surface is now implemented as a checked-in `npm/` launcher package plus root-flake `release-npm-package`, which packs `sce-v-npm.tgz`, refuses mismatched checked-in package metadata, and installs the native CLI by downloading the release manifest plus detached signature, verifying the manifest with the bundled npm public key, and only then checksum-verifying the matching GitHub release archive at npm `postinstall` time. GitHub Releases are the canonical publication surface for those release artifacts, while crates.io and npm registry publication are separate non-bumping publish stages under the approved release topology. GitHub CLI release automation now lives in dedicated `release-sce*.yml` workflows split by Linux, Linux ARM, and macOS ARM, and `.github/workflows/release-sce.yml` now orchestrates those three reusable platform lanes before assembling the signed release manifest, npm tarball, and GitHub release payload. The orchestrator now tags/releases the checked-in `.version` directly and rejects version mismatches instead of generating a new semver during workflow execution, `.github/workflows/publish-crates.yml` is the dedicated crates.io publish stage triggered from a published GitHub release or manual dispatch with the same `.version`/tag/Cargo parity checks and a clean temporary repo copy for Cargo packaging, and `release-agents.yml` remains Tessl-only. diff --git a/context/patterns.md b/context/patterns.md index d2fb6b46..cd5b015c 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -19,6 +19,7 @@ - Keep repository-owned OpenCode plugin support code under one shared `config/lib/` Bun/TypeScript package root; package metadata and lockfile ownership live at `config/lib/package.json` and `config/lib/bun.lock`, not under individual plugin subdirectories. - Provide Biome through the root Nix dev shell so contributors can run `nix develop -c biome ...` without a host-installed binary or package-local setup. - When exposing JS validation through `nix flake check`, split Bun test, Biome lint/check, and Biome format verification into separately named derivations per target directory so failures stay tool- and surface-specific. +- When `config/lib` tests depend on shared repo fixtures, keep the Nix check source repo-shaped and run tools from `config/lib/` instead of flattening the package root and breaking repo-relative fixture paths. ## Flake app entrypoints diff --git a/flake.nix b/flake.nix index 40975148..98a1079e 100644 --- a/flake.nix +++ b/flake.nix @@ -118,7 +118,7 @@ }; configLibBashPolicySrc = pkgs.lib.fileset.toSource { - root = ./config/lib; + root = workspaceRoot; fileset = pkgs.lib.fileset.unions [ ./config/lib/package.json ./config/lib/bun.lock @@ -127,6 +127,7 @@ ./config/lib/bash-policy-plugin/bash-policy/runtime.ts ./config/lib/bash-policy-plugin/bash-policy-runtime.test.ts ./config/lib/bash-policy-plugin/opencode-bash-policy-plugin.ts + (pkgs.lib.fileset.maybeMissing ./cli/src/services/patch/fixtures/diff_creation) ]; }; @@ -852,9 +853,9 @@ set -euo pipefail # Copy source files - cp -r "${configLibBashPolicySrc}" ./config-lib - chmod -R u+w ./config-lib - cd ./config-lib + cp -r "${configLibBashPolicySrc}" ./repo + chmod -R u+w ./repo + cd ./repo/config/lib # Use pre-fetched dependencies from FOD cp -r "${configLibBashPolicyDeps}/node_modules" ./ @@ -873,9 +874,9 @@ '' set -euo pipefail - cp -r "${configLibBashPolicySrc}" ./config-lib - chmod -R u+w ./config-lib - cd ./config-lib + cp -r "${configLibBashPolicySrc}" ./repo + chmod -R u+w ./repo + cd ./repo/config/lib biome check --formatter-enabled=false . @@ -890,9 +891,9 @@ '' set -euo pipefail - cp -r "${configLibBashPolicySrc}" ./config-lib - chmod -R u+w ./config-lib - cd ./config-lib + cp -r "${configLibBashPolicySrc}" ./repo + chmod -R u+w ./repo + cd ./repo/config/lib biome check --linter-enabled=false . From 8172e87f20707b520a5a58baf9a953ecb7151e8d Mon Sep 17 00:00:00 2001 From: Ivan Ivic Date: Wed, 10 Jun 2026 11:06:31 +0200 Subject: [PATCH 09/18] trace: Remove redundant PostToolUse forwarding comment Co-authored-by: SCE --- config/.claude/plugins/sce-agent-trace.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/config/.claude/plugins/sce-agent-trace.ts b/config/.claude/plugins/sce-agent-trace.ts index b7a3ac75..bb0c52e7 100644 --- a/config/.claude/plugins/sce-agent-trace.ts +++ b/config/.claude/plugins/sce-agent-trace.ts @@ -711,7 +711,6 @@ export async function runClaudeHookRuntime( return; } - // For PostToolUse, attempt best-effort diff-trace forwarding if (eventName !== "PostToolUse") { return; } From bf06d93b0ba31b6de9578728bfa9f39d1ac2ba53 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 13:21:02 +0200 Subject: [PATCH 10/18] cli: Add Rust Claude hook payload derivation model Introduce cli/src/services/structured_patch.rs to convert Claude PostToolUse structured payloads into canonical ParsedPatch values. Supports Write create and Edit structured patch tools with deterministic skip reasons for unsupported events, tools, or payload shapes. Register the module in services/mod.rs. Co-authored-by: SCE --- cli/src/services/mod.rs | 1 + cli/src/services/structured_patch.rs | 463 ++++++++++++++++++++++++ context/architecture.md | 1 + context/cli/patch-service.md | 1 + context/cli/structured-patch-service.md | 35 ++ context/context-map.md | 1 + context/glossary.md | 1 + context/plans/claude-rust-diff-trace.md | 93 +++++ 8 files changed, 596 insertions(+) create mode 100644 cli/src/services/structured_patch.rs create mode 100644 context/cli/structured-patch-service.md create mode 100644 context/plans/claude-rust-diff-trace.md diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index 829dda64..ff3971cc 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -23,6 +23,7 @@ pub mod patch; pub mod resilience; pub mod security; pub mod setup; +pub mod structured_patch; pub mod style; pub mod token_storage; pub mod version; diff --git a/cli/src/services/structured_patch.rs b/cli/src/services/structured_patch.rs new file mode 100644 index 00000000..91675923 --- /dev/null +++ b/cli/src/services/structured_patch.rs @@ -0,0 +1,463 @@ +//! Structured editor-hook patch derivation. +//! +//! This module converts supported structured tool payloads into the CLI's +//! canonical [`ParsedPatch`](crate::services::patch::ParsedPatch) domain model +//! without going through rendered unified-diff text. The first supported source +//! is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured +//! patches. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value}; + +use crate::services::patch::{ + FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaudeStructuredPatch { + pub session_id: String, + pub patch: ParsedPatch, + pub time: u64, + pub tool_name: String, + pub tool_version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ClaudeStructuredPatchDerivationResult { + Derived(ClaudeStructuredPatch), + Skipped(ClaudeStructuredPatchSkipReason), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClaudeStructuredPatchSkipReason { + UnsupportedEvent, + EventWithoutDiffTrace, + InvalidPayload, + EventNameMismatch, + UnsupportedTool, + UnsupportedWritePayload, + MissingFilePath, + MissingFileContent, + UnsupportedEditPayload, + MissingSessionId, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PatchBuildResult { + Built(ParsedPatch), + Skipped(ClaudeStructuredPatchSkipReason), +} + +const CLAUDE_TOOL_NAME: &str = "claude"; + +pub fn derive_claude_structured_patch( + event_name: &str, + payload: &Value, + time: u64, + tool_version: Option<&str>, +) -> ClaudeStructuredPatchDerivationResult { + match event_name { + "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "Stop" => {} + _ => return skipped(ClaudeStructuredPatchSkipReason::UnsupportedEvent), + } + + if event_name != "PostToolUse" { + return skipped(ClaudeStructuredPatchSkipReason::EventWithoutDiffTrace); + } + + let Some(payload_object) = payload.as_object() else { + return skipped(ClaudeStructuredPatchSkipReason::InvalidPayload); + }; + + if let Some(payload_event_name) = string_field(payload_object, &["hook_event_name"]) { + if payload_event_name != event_name { + return skipped(ClaudeStructuredPatchSkipReason::EventNameMismatch); + } + } + + let patch = match build_claude_post_tool_use_patch(payload_object) { + PatchBuildResult::Built(patch) => patch, + PatchBuildResult::Skipped(reason) => return skipped(reason), + }; + + let Some(session_id) = string_field(payload_object, &["session_id", "sessionID"]) else { + return skipped(ClaudeStructuredPatchSkipReason::MissingSessionId); + }; + + ClaudeStructuredPatchDerivationResult::Derived(ClaudeStructuredPatch { + session_id, + patch, + time, + tool_name: CLAUDE_TOOL_NAME.to_string(), + tool_version: extract_claude_tool_version(tool_version, payload_object), + }) +} + +fn build_claude_post_tool_use_patch(payload: &Map) -> PatchBuildResult { + match string_field(payload, &["tool_name"]).as_deref() { + Some("Write") => build_write_create_patch(payload), + Some("Edit") => build_edit_structured_patch(payload), + _ => skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedTool), + } +} + +fn build_write_create_patch(payload: &Map) -> PatchBuildResult { + let Some(tool_input) = object_field(payload, "tool_input") else { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedWritePayload); + }; + let Some(tool_response) = object_field(payload, "tool_response") else { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedWritePayload); + }; + + if value_field(tool_response, &["originalFile", "original_file"]) != Some(&Value::Null) { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedWritePayload); + } + + let file_path = normalize_patch_path( + string_field(tool_input, &["file_path", "filePath"]) + .or_else(|| string_field(tool_response, &["file_path", "filePath"])) + .as_deref(), + string_field(payload, &["cwd"]).as_deref(), + ); + let Some(file_path) = file_path else { + return skipped_build(ClaudeStructuredPatchSkipReason::MissingFilePath); + }; + + let Some(content) = string_value_field(tool_input, &["content", "newFile", "new_file"]) else { + return skipped_build(ClaudeStructuredPatchSkipReason::MissingFileContent); + }; + + PatchBuildResult::Built(write_create_patch(file_path, &content)) +} + +fn build_edit_structured_patch(payload: &Map) -> PatchBuildResult { + let Some(tool_input) = object_field(payload, "tool_input") else { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedEditPayload); + }; + let Some(tool_response) = object_field(payload, "tool_response") else { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedEditPayload); + }; + + let Some(structured_patch) = + value_field(tool_response, &["structuredPatch", "structured_patch"]) + else { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedEditPayload); + }; + if structured_patch.is_null() { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedEditPayload); + } + + let file_path = normalize_patch_path( + string_field(tool_input, &["file_path", "filePath"]) + .or_else(|| { + structured_patch + .as_object() + .and_then(|patch| string_field(patch, &["file_path", "filePath", "path"])) + }) + .as_deref(), + string_field(payload, &["cwd"]).as_deref(), + ); + let Some(file_path) = file_path else { + return skipped_build(ClaudeStructuredPatchSkipReason::MissingFilePath); + }; + + let hunks: Vec = structured_patch_hunks(structured_patch) + .into_iter() + .filter_map(parse_structured_patch_hunk) + .collect(); + + if hunks.is_empty() { + return skipped_build(ClaudeStructuredPatchSkipReason::UnsupportedEditPayload); + } + + PatchBuildResult::Built(ParsedPatch { + files: vec![PatchFileChange { + old_path: file_path.clone(), + new_path: file_path, + kind: FileChangeKind::Modified, + hunks, + }], + }) +} + +fn write_create_patch(file_path: String, content: &str) -> ParsedPatch { + let content_lines = split_file_content(content); + let lines = content_lines + .into_iter() + .enumerate() + .map(|(index, content)| TouchedLine { + kind: TouchedLineKind::Added, + line_number: u64::try_from(index + 1).expect("line index should fit in u64"), + content, + }) + .collect::>(); + let new_count = u64::try_from(lines.len()).expect("line count should fit in u64"); + + ParsedPatch { + files: vec![PatchFileChange { + old_path: String::new(), + new_path: file_path, + kind: FileChangeKind::Added, + hunks: (!lines.is_empty()) + .then_some(PatchHunk { + old_start: 0, + old_count: 0, + new_start: 1, + new_count, + model_id: None, + lines, + }) + .into_iter() + .collect(), + }], + } +} + +fn parse_structured_patch_hunk(hunk_value: &Value) -> Option { + let hunk = hunk_value.as_object()?; + let raw_lines = array_field(hunk, &["lines", "body", "changes"])?; + let old_start = numeric_field(hunk, &["oldStart", "old_start", "oldLine", "old_line"])?; + let new_start = numeric_field(hunk, &["newStart", "new_start", "newLine", "new_line"])?; + let old_count = numeric_field(hunk, &["oldCount", "old_count", "oldLines", "old_lines"]) + .unwrap_or_else(|| count_old_hunk_lines(raw_lines)); + let new_count = numeric_field(hunk, &["newCount", "new_count", "newLines", "new_lines"]) + .unwrap_or_else(|| count_new_hunk_lines(raw_lines)); + + let mut old_line_number = old_start; + let mut new_line_number = new_start; + let mut touched_lines = Vec::new(); + + for raw_line in raw_lines { + match structured_patch_line(raw_line) { + Some(StructuredPatchLine::Context) => { + old_line_number += 1; + new_line_number += 1; + } + Some(StructuredPatchLine::Added(content)) => { + touched_lines.push(TouchedLine { + kind: TouchedLineKind::Added, + line_number: new_line_number, + content, + }); + new_line_number += 1; + } + Some(StructuredPatchLine::Removed(content)) => { + touched_lines.push(TouchedLine { + kind: TouchedLineKind::Removed, + line_number: old_line_number, + content, + }); + old_line_number += 1; + } + Some(StructuredPatchLine::NoNewlineMarker) | None => {} + } + } + + (!touched_lines.is_empty()).then_some(PatchHunk { + old_start, + old_count, + new_start, + new_count, + model_id: None, + lines: touched_lines, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum StructuredPatchLine { + Context, + Added(String), + Removed(String), + NoNewlineMarker, +} + +fn structured_patch_line(line_value: &Value) -> Option { + if let Some(line) = line_value.as_str() { + if line.starts_with('\\') { + return Some(StructuredPatchLine::NoNewlineMarker); + } + if let Some(content) = line.strip_prefix('+') { + return Some(StructuredPatchLine::Added(content.to_string())); + } + if let Some(content) = line.strip_prefix('-') { + return Some(StructuredPatchLine::Removed(content.to_string())); + } + return Some(StructuredPatchLine::Context); + } + + let line = line_value.as_object()?; + let content = string_value_field(line, &["content", "text", "value"])?; + match string_field(line, &["kind", "type", "operation", "change"]).as_deref() { + Some("context" | "unchanged" | "equal" | " ") => Some(StructuredPatchLine::Context), + Some("added" | "add" | "insert" | "+") => Some(StructuredPatchLine::Added(content)), + Some("removed" | "remove" | "delete" | "-") => Some(StructuredPatchLine::Removed(content)), + _ => None, + } +} + +fn structured_patch_hunks(structured_patch: &Value) -> Vec<&Value> { + if let Some(hunks) = structured_patch.as_array() { + return hunks.iter().collect(); + } + + let Some(patch) = structured_patch.as_object() else { + return Vec::new(); + }; + + if let Some(hunks) = array_field(patch, &["hunks", "changes"]) { + return hunks.iter().collect(); + } + + if array_field(patch, &["lines", "body"]).is_some() { + return vec![structured_patch]; + } + + Vec::new() +} + +fn split_file_content(content: &str) -> Vec { + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + if normalized.is_empty() { + return Vec::new(); + } + + normalized + .strip_suffix('\n') + .unwrap_or(&normalized) + .split('\n') + .map(ToString::to_string) + .collect() +} + +fn count_old_hunk_lines(lines: &[Value]) -> u64 { + count_lines(lines, |line| !matches!(line, StructuredPatchLine::Added(_))) +} + +fn count_new_hunk_lines(lines: &[Value]) -> u64 { + count_lines(lines, |line| { + !matches!(line, StructuredPatchLine::Removed(_)) + }) +} + +fn count_lines(lines: &[Value], include: fn(&StructuredPatchLine) -> bool) -> u64 { + let count = lines + .iter() + .filter_map(structured_patch_line) + .filter(|line| !matches!(line, StructuredPatchLine::NoNewlineMarker) && include(line)) + .count(); + + u64::try_from(count).expect("line count should fit in u64") +} + +fn extract_claude_tool_version( + input_tool_version: Option<&str>, + payload: &Map, +) -> Option { + if let Some(version) = input_tool_version + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(version.to_string()); + } + + for key in ["tool_version", "claude_version", "version"] { + match normalize_optional_version(payload.get(key)) { + VersionField::Present(version) => return version, + VersionField::Missing => {} + } + } + + None +} + +enum VersionField { + Missing, + Present(Option), +} + +fn normalize_optional_version(value: Option<&Value>) -> VersionField { + match value { + None => VersionField::Missing, + Some(Value::String(version)) => { + let normalized = version.trim(); + VersionField::Present((!normalized.is_empty()).then(|| normalized.to_string())) + } + Some(Value::Null | _) => VersionField::Present(None), + } +} + +fn normalize_patch_path(file_path: Option<&str>, cwd: Option<&str>) -> Option { + let mut normalized = file_path?.trim().to_string(); + if normalized.is_empty() { + return None; + } + + if let Some(cwd) = cwd.map(str::trim).filter(|value| !value.is_empty()) { + let path = Path::new(&normalized); + let cwd_path = Path::new(cwd); + if path.is_absolute() && cwd_path.is_absolute() { + if let Ok(relative_path) = path.strip_prefix(cwd_path) { + if !relative_path.as_os_str().is_empty() { + normalized = path_to_forward_slashes(relative_path); + } + } + } + } + + normalized = normalized.replace('\\', "/"); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped.to_string(); + } + + if normalized.is_empty() || normalized == "." { + None + } else { + Some(normalized) + } +} + +fn path_to_forward_slashes(path: &Path) -> String { + path.components() + .collect::() + .to_string_lossy() + .replace('\\', "/") +} + +fn object_field<'a>(payload: &'a Map, key: &str) -> Option<&'a Map> { + payload.get(key)?.as_object() +} + +fn string_field(payload: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = payload.get(*key)?.as_str()?.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +fn string_value_field(payload: &Map, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| payload.get(*key)?.as_str().map(ToString::to_string)) +} + +fn numeric_field(payload: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| payload.get(*key)?.as_u64()) +} + +fn array_field<'a>(payload: &'a Map, keys: &[&str]) -> Option<&'a Vec> { + keys.iter().find_map(|key| payload.get(*key)?.as_array()) +} + +fn value_field<'a>(payload: &'a Map, keys: &[&str]) -> Option<&'a Value> { + keys.iter().find_map(|key| payload.get(*key)) +} + +fn skipped(reason: ClaudeStructuredPatchSkipReason) -> ClaudeStructuredPatchDerivationResult { + ClaudeStructuredPatchDerivationResult::Skipped(reason) +} + +fn skipped_build(reason: ClaudeStructuredPatchSkipReason) -> PatchBuildResult { + PatchBuildResult::Skipped(reason) +} diff --git a/context/architecture.md b/context/architecture.md index 1ac0a79a..696ab807 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -121,6 +121,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - No user-invocable `sce sync` command is wired in the current runtime; local DB and Agent Trace DB bootstrap flows through lifecycle providers aggregated by setup, and DB health/repair flows through the doctor surface. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. +- `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It currently derives Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free and is not yet wired into `sce hooks diff-trace`; that runtime integration is deferred to the active Claude Rust diff-trace migration plan. - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, version, completion, help, patch, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` trait, `RuntimeCommandHandle` type alias, `CommandRegistry` struct, and `build_default_registry()` function for the command dispatch registry. Service-owned command modules now own the migrated runtime command structs and `RuntimeCommand` impls for help/help-text, version, completion, auth, config, setup, doctor, and hooks. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs Bun-discovered tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index b344e0d2..a9bbaac0 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -94,6 +94,7 @@ Patch reconstruction tests use deterministic fixture suites under `cli/src/servi ## See also +- [structured-patch-service.md](structured-patch-service.md) - [overview.md](../overview.md) - [architecture.md](../architecture.md) - [glossary.md](../glossary.md) diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md new file mode 100644 index 00000000..4fa59aa2 --- /dev/null +++ b/context/cli/structured-patch-service.md @@ -0,0 +1,35 @@ +# Structured Patch Service + +`cli/src/services/structured_patch.rs` owns synchronous, pure conversion from structured editor hook payloads into the canonical `ParsedPatch` model from `cli/src/services/patch.rs`. + +## Current scope + +- Supported source: Claude `PostToolUse` structured payloads. +- Supported tools: + - `Write` create payloads where `tool_response.originalFile` / `original_file` is `null`. + - `Edit` payloads with `tool_response.structuredPatch` / `structured_patch` hunks. +- Output: `ClaudeStructuredPatch` with `session_id`, `ParsedPatch`, fixed caller-provided `time`, `tool_name="claude"`, and nullable `tool_version`. +- Failure mode: `ClaudeStructuredPatchDerivationResult::Skipped(...)` with deterministic skip reasons for unsupported events/tools/payload shapes or missing required fields. + +## ParsedPatch contract + +- Write-create payloads produce `FileChangeKind::Added` file entries. +- Empty Write content produces an added file with no hunks. +- Edit structured hunks produce `FileChangeKind::Modified` file entries. +- Structured hunk context lines advance old and new line counters but are not stored as touched lines. +- Added/removed structured lines become `TouchedLineKind::Added` / `TouchedLineKind::Removed` entries with line numbers derived from hunk starts. +- The service does not render unified-diff text; downstream consumers can serialize or intersect `ParsedPatch` directly. + +## Runtime wiring status + +The module is not wired into `sce hooks diff-trace` yet. Current hook runtime still accepts normalized diff-trace JSON with raw diff text. Runtime integration is planned in `context/plans/claude-rust-diff-trace.md` T03. + +## Test status + +Golden fixture migration from `cli/src/services/patch/fixtures/diff_creation/` is deferred to T02. No generated helper tests are kept for this T01 slice. + +## See also + +- [patch-service.md](patch-service.md) +- [agent-trace-hooks-command-routing.md](../sce/agent-trace-hooks-command-routing.md) +- [context-map.md](../context-map.md) diff --git a/context/context-map.md b/context/context-map.md index 974b4076..ea2a68b4 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -13,6 +13,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, set operations, and checked-in reconstruction/diff-creation fixture suites for `cli/src/services/patch.rs`; covers touched-line parsing for `Index:` SVN-style and `diff --git` git-style formats, `ParseError`/`PatchLoadError` diagnostics, `intersect_patches` target-shaped overlap with `model_id` provenance inheritance, `combine_patches` later-wins conflict resolution, active post-commit hook consumption, and the Claude derivation `diff_creation/` golden fixtures) +- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; not wired into hook runtime until the planned T03 integration) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) diff --git a/context/glossary.md b/context/glossary.md index c006baf6..4cc5a567 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -35,6 +35,7 @@ - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. - `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..008`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)`, with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). +- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; hook-runtime wiring is deferred to the planned T03 integration. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, and nullable `tool_version` for parameterized writes to the `diff_traces` table. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md new file mode 100644 index 00000000..f0e71897 --- /dev/null +++ b/context/plans/claude-rust-diff-trace.md @@ -0,0 +1,93 @@ +# Plan: Claude Rust Diff Trace + +## Change summary + +Move Claude agent-trace hook derivation out of generated TypeScript and into the Rust `sce` CLI so generated `.claude/settings.json` invokes `sce hooks` directly. The new Claude `PostToolUse` flow should send the structured Claude hook payload to `sce hooks diff-trace`, where Rust derives the patch and persists the existing normalized diff-trace data. Claude `SessionStart` model attribution should continue to flow through `sce hooks session-model`, also called directly from Claude settings. OpenCode TypeScript plugin behavior remains in scope only as an unchanged consumer of the existing normalized `diff-trace` payload. + +Recent commit input considered: + +- `8172e87 trace: Remove redundant PostToolUse forwarding comment` +- `5b8cbfb flake: Preserve repo-shaped config-lib check source` +- `d9b972b hooks: Unify STDIN payload validation helpers` +- `01a6359 patch: Remove unused diff_creation input fixtures` +- `f72fd3d agent-trace: Add Claude derivation golden tests` +- `d8169cf hooks: Make model_id optional with session_models resolution` +- `e2d242b cli: Remove unused tempfile dev dependency` +- `88a17f6 agent-trace: Replace raw Claude capture with normalized session-model intake` +- `7937112 feat(claude): Add raw hook capture and Claude agent configuration` + +Planning interpretation: the last nine commits created and then refined a Claude TypeScript bridge that derives normalized diff traces, added reusable fixture coverage, removed raw capture, and stabilized Rust-side validation/model attribution. This plan preserves those validated contracts while moving the remaining Claude-specific derivation and golden coverage into Rust. + +## Success criteria + +- Generated Claude settings call `sce hooks session-model` and `sce hooks diff-trace` directly; they no longer execute Bun or `.claude/plugins/sce-agent-trace.ts`. +- `sce hooks diff-trace` can accept Claude structured `PostToolUse` payloads and derive the same patch output currently covered by the `diff_creation` golden fixtures. +- Existing OpenCode normalized `diff-trace` payloads remain accepted and behaviorally unchanged. +- Claude TypeScript plugin source, generated plugin output, and Claude TypeScript golden tests are removed from the repo-owned Claude path. +- Golden fixture coverage for Claude diff derivation lives in Rust and validates the checked-in `cli/src/services/patch/fixtures/diff_creation/` scenarios. +- Generated output parity and full repo validation pass. + +## Constraints and non-goals + +- In scope: Rust hook intake/derivation, Rust tests, Pkl-generated Claude settings, generated output updates, context sync. +- In scope: removing Claude-specific TypeScript plugin source/tests and generated `.claude/plugins` output. +- Out of scope: changing OpenCode TypeScript plugin behavior or removing OpenCode TypeScript runtime code. +- Out of scope: changing post-commit Agent Trace payload semantics, AgentTraceDb schema, or OpenCode plugin registration. +- Out of scope: adding a new external dependency unless implementation proves the existing Rust stack cannot parse the structured Claude payload safely. +- Preserve one-task/one-atomic-commit slicing; each executable task below should land independently. + +## Task stack + +- [x] T01: `Add Rust Claude hook payload derivation model` (status:done) + - Task ID: T01 + - Goal: Add Rust data models and pure derivation helpers that convert supported Claude `PostToolUse` structured payloads into the existing normalized diff-trace shape. + - Boundaries (in/out of scope): In - Rust-only parsing/normalization helpers for Claude `Write` create and `Edit` structured-patch payloads, status/skip reasons matching the current supported cases, no CLI routing changes. Out - generated settings changes, TypeScript deletion, DB persistence changes, OpenCode flow changes. + - Done when: Rust exposes a testable pure function that accepts event name + JSON payload + fixed time/tool version inputs and returns derived `{ sessionID, diff, time, tool_name="claude", tool_version }` or deterministic skip/error results for unsupported payloads. + - Verification notes (commands or checks): Run the narrow Rust tests added for the derivation helper via `nix develop -c sh -c 'cd cli && cargo test claude'` if a narrow test target exists; otherwise use the narrowest relevant `cargo test` selector. Final full validation remains T07. + - Completion evidence (2026-06-10): Added synchronous Rust `structured_patch` service module; Claude `PostToolUse` `Write` and `Edit` structured payload derivation returns `ParsedPatch`-backed `ClaudeStructuredPatch` results with deterministic skip reasons and fixed time/tool-version inputs. Generated helper tests were removed after review; golden fixture coverage remains deferred to T02. `nix flake check` passed. `nix run .#pkl-check-generated` passed. Direct narrow `cargo test claude` was not run because the repo bash policy blocks direct Cargo test commands in favor of `nix flake check`. + +- [ ] T02: `Move Claude diff-creation golden tests to Rust` (status:todo) + - Task ID: T02 + - Goal: Recreate the current Claude derivation golden coverage in Rust against `cli/src/services/patch/fixtures/diff_creation/`. + - Boundaries (in/out of scope): In - Rust tests that discover/validate the expected eight fixture scenarios and assert derived patch equality using `claude-post-tool-use.json` plus `expected.patch`. Out - deleting TypeScript tests/source, modifying fixture contents except for necessary fixture-contract corrections, changing OpenCode tests. + - Done when: Rust tests fail on missing/extra scenarios, use fixed time/tool-version inputs, assert `sessionID`, `tool_name="claude"`, nullable/omitted `model_id` behavior as appropriate, and exact diff output for each golden fixture. + - Verification notes (commands or checks): Run the narrow Rust golden test selector through Nix, for example `nix develop -c sh -c 'cd cli && cargo test claude_derivation'` once test names are known. + +- [ ] T03: `Teach sce hooks diff-trace to accept Claude structured payloads` (status:todo) + - Task ID: T03 + - Goal: Extend the Rust `sce hooks diff-trace` STDIN intake so Claude structured `PostToolUse` payloads are derived in Rust and then pass through the existing diff-trace persistence path. + - Boundaries (in/out of scope): In - payload classification, validation errors/skips, derivation-to-existing `DiffTracePayload` adapter, tests for Claude structured payload runtime path and existing normalized payload compatibility. Out - new DB schema, post-commit flow changes, OpenCode plugin changes, generated settings changes. + - Done when: `diff-trace` accepts both the existing normalized payload and the new Claude structured payload; Claude unsupported/no-diff cases produce deterministic success/no-op or validation behavior consistent with current hook semantics; OpenCode normalized payload tests still pass unchanged. + - Verification notes (commands or checks): Run focused hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test hooks'`; include a targeted exact test when available. + +- [ ] T04: `Render Claude settings with direct sce hook commands` (status:todo) + - Task ID: T04 + - Goal: Update canonical Pkl-generated Claude settings so Claude invokes `sce hooks session-model` and `sce hooks diff-trace` directly instead of running Bun against `.claude/plugins/sce-agent-trace.ts`. + - Boundaries (in/out of scope): In - `config/pkl/renderers/claude-content.pkl` settings command definitions and regenerated `config/.claude/settings.json` / repo-root `.claude/settings.json` outputs. Out - OpenCode renderer/plugin registration, agent/skill content changes unrelated to settings, manual edits to generated outputs without source updates. + - Done when: Generated Claude settings contain no `.claude/plugins/sce-agent-trace.ts` or `bun` hook invocation for agent tracing, and route `SessionStart` to `sce hooks session-model` while routing matched `PostToolUse` to `sce hooks diff-trace` with Claude hook payload on STDIN according to Claude hook command behavior. + - Verification notes (commands or checks): Run `nix develop -c pkl eval -m . config/pkl/generate.pkl` after source edits, then `nix run .#pkl-check-generated`. + +- [ ] T05: `Remove Claude TypeScript plugin source and generated outputs` (status:todo) + - Task ID: T05 + - Goal: Delete the now-obsolete Claude TypeScript agent-trace runtime and its TypeScript golden tests while preserving OpenCode TypeScript plugin/runtime code. + - Boundaries (in/out of scope): In - remove `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts`, its Bun test, generated `config/.claude/plugins/sce-agent-trace.ts`, generated root `.claude/plugins/sce-agent-trace.ts`, and references that assume a Claude plugin path exists. Out - `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts`, OpenCode generated plugins, bash-policy code. + - Done when: No repo-owned Claude `.claude/plugins` agent-trace TypeScript remains; config-lib package/test configuration no longer expects the deleted Claude test; generated output parity is clean after regeneration. + - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T07 full validation. + +- [ ] T06: `Sync current-state context for Rust-owned Claude tracing` (status:todo) + - Task ID: T06 + - Goal: Update durable context to describe the new Rust-owned Claude derivation boundary and removal of Claude TypeScript plugin runtime. + - Boundaries (in/out of scope): In - focused updates to `context/sce/opencode-agent-trace-plugin-runtime.md`, `context/sce/agent-trace-hooks-command-routing.md`, `context/cli/patch-service.md`, `context/context-map.md`, `context/overview.md`, and glossary/architecture entries if needed. Out - historical narration beyond current-state facts, unrelated context cleanup. + - Done when: Context says OpenCode still uses TypeScript normalized diff traces, Claude settings call `sce hooks` directly, Rust derives Claude structured patches, and golden tests are Rust-owned. + - Verification notes (commands or checks): Review context references for stale `.claude/plugins/sce-agent-trace.ts`, Claude TypeScript golden test, and shared TypeScript-runtime-to-Rust boundary claims. + +- [ ] T07: `Validate and clean up Claude Rust diff-trace migration` (status:todo) + - Task ID: T07 + - Goal: Run final validation, remove temporary scaffolding, and record plan completion evidence. + - Boundaries (in/out of scope): In - full repo validation, generated-output parity, checking for stale Claude TypeScript references, updating this plan with validation evidence. Out - new feature work or unrelated refactors discovered during validation. + - Done when: `nix run .#pkl-check-generated` and `nix flake check` pass; no stale Claude plugin TypeScript files/references remain except intentional historical references; plan status/evidence is updated. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; targeted search for `.claude/plugins/sce-agent-trace.ts`, `deriveClaudeDiffTracePayload`, and Claude TypeScript golden-test references. + +## Open questions + +- None blocking. User clarified that Claude derivation should happen fully in Rust, Claude TypeScript should be removed, OpenCode TypeScript should remain, and generated Claude settings should call `sce hooks` directly. From 57db21dda46da4fb6b13f1689094369af2c0fabb Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 14:37:58 +0200 Subject: [PATCH 11/18] cli: Move Claude diff-creation golden tests to Rust Migrate the Claude PostToolUse diff-creation golden fixture coverage from TypeScript to Rust. Co-authored-by: SCE --- cli/src/services/structured_patch.rs | 4 + .../edit_multi_hunk/claude-post-tool-use.json | 0 .../fixtures}/edit_multi_hunk/expected.patch | 0 .../claude-post-tool-use.json | 0 .../edit_only_additions/expected.patch | 0 .../claude-post-tool-use.json | 0 .../edit_only_deletions/expected.patch | 0 .../claude-post-tool-use.json | 0 .../fixtures}/edit_single_hunk/expected.patch | 0 .../claude-post-tool-use.json | 0 .../write_create_empty/expected.patch | 0 .../claude-post-tool-use.json | 0 .../write_create_multiline/expected.patch | 0 .../claude-post-tool-use.json | 0 .../write_create_no_newline/expected.patch | 0 .../claude-post-tool-use.json | 0 .../write_create_simple/expected.patch | 0 cli/src/services/structured_patch/tests.rs | 150 ++++++++++++++++++ .../claude-sce-agent-trace-plugin.test.ts | 2 +- context/architecture.md | 4 +- context/cli/patch-service.md | 5 +- context/cli/structured-patch-service.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/plans/claude-rust-diff-trace.md | 7 +- .../opencode-agent-trace-plugin-runtime.md | 8 +- flake.nix | 3 +- 28 files changed, 174 insertions(+), 17 deletions(-) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_multi_hunk/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_multi_hunk/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_only_additions/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_only_additions/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_only_deletions/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_only_deletions/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_single_hunk/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/edit_single_hunk/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_empty/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_empty/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_multiline/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_multiline/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_no_newline/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_no_newline/expected.patch (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_simple/claude-post-tool-use.json (100%) rename cli/src/services/{patch/fixtures/diff_creation => structured_patch/fixtures}/write_create_simple/expected.patch (100%) create mode 100644 cli/src/services/structured_patch/tests.rs diff --git a/cli/src/services/structured_patch.rs b/cli/src/services/structured_patch.rs index 91675923..68d8e734 100644 --- a/cli/src/services/structured_patch.rs +++ b/cli/src/services/structured_patch.rs @@ -461,3 +461,7 @@ fn skipped(reason: ClaudeStructuredPatchSkipReason) -> ClaudeStructuredPatchDeri fn skipped_build(reason: ClaudeStructuredPatchSkipReason) -> PatchBuildResult { PatchBuildResult::Skipped(reason) } + +#[cfg(test)] +#[path = "structured_patch/tests.rs"] +mod tests; diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/edit_multi_hunk/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/edit_multi_hunk/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch b/cli/src/services/structured_patch/fixtures/edit_multi_hunk/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_multi_hunk/expected.patch rename to cli/src/services/structured_patch/fixtures/edit_multi_hunk/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/edit_only_additions/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_only_additions/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/edit_only_additions/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch b/cli/src/services/structured_patch/fixtures/edit_only_additions/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_only_additions/expected.patch rename to cli/src/services/structured_patch/fixtures/edit_only_additions/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/edit_only_deletions/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/edit_only_deletions/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch b/cli/src/services/structured_patch/fixtures/edit_only_deletions/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_only_deletions/expected.patch rename to cli/src/services/structured_patch/fixtures/edit_only_deletions/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/edit_single_hunk/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/edit_single_hunk/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch b/cli/src/services/structured_patch/fixtures/edit_single_hunk/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/edit_single_hunk/expected.patch rename to cli/src/services/structured_patch/fixtures/edit_single_hunk/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/write_create_empty/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_empty/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/write_create_empty/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch b/cli/src/services/structured_patch/fixtures/write_create_empty/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_empty/expected.patch rename to cli/src/services/structured_patch/fixtures/write_create_empty/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/write_create_multiline/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_multiline/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/write_create_multiline/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch b/cli/src/services/structured_patch/fixtures/write_create_multiline/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_multiline/expected.patch rename to cli/src/services/structured_patch/fixtures/write_create_multiline/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/write_create_no_newline/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/write_create_no_newline/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch b/cli/src/services/structured_patch/fixtures/write_create_no_newline/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_no_newline/expected.patch rename to cli/src/services/structured_patch/fixtures/write_create_no_newline/expected.patch diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json b/cli/src/services/structured_patch/fixtures/write_create_simple/claude-post-tool-use.json similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_simple/claude-post-tool-use.json rename to cli/src/services/structured_patch/fixtures/write_create_simple/claude-post-tool-use.json diff --git a/cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch b/cli/src/services/structured_patch/fixtures/write_create_simple/expected.patch similarity index 100% rename from cli/src/services/patch/fixtures/diff_creation/write_create_simple/expected.patch rename to cli/src/services/structured_patch/fixtures/write_create_simple/expected.patch diff --git a/cli/src/services/structured_patch/tests.rs b/cli/src/services/structured_patch/tests.rs new file mode 100644 index 00000000..8ef838c1 --- /dev/null +++ b/cli/src/services/structured_patch/tests.rs @@ -0,0 +1,150 @@ +use std::fs; + +use serde_json::Value; + +use super::{derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult}; +use crate::services::patch::parse_patch; + +const FIXED_TIME: u64 = 1_700_000_000_000; +const FIXED_TOOL_VERSION: &str = "test-claude-version"; +const EXPECTED_SCENARIOS: &[&str] = &[ + "write_create_simple", + "write_create_empty", + "write_create_no_newline", + "write_create_multiline", + "edit_single_hunk", + "edit_multi_hunk", + "edit_only_additions", + "edit_only_deletions", +]; + +fn fixture_root() -> std::path::PathBuf { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.join("src/services/structured_patch/fixtures") +} + +fn discover_fixture_scenarios() -> Vec { + let root = fixture_root(); + let mut scenarios = Vec::new(); + for entry in fs::read_dir(&root).expect("fixture root should exist") { + let entry = entry.expect("fixture entry should be readable"); + if entry + .file_type() + .expect("file type should be readable") + .is_dir() + { + scenarios.push(entry.file_name().to_string_lossy().to_string()); + } + } + scenarios.sort(); + scenarios +} + +fn ordered_fixture_scenarios() -> Vec { + let discovered = discover_fixture_scenarios(); + + let missing: Vec<&str> = EXPECTED_SCENARIOS + .iter() + .copied() + .filter(|name| !discovered.contains(&name.to_string())) + .collect(); + let extra: Vec<&str> = discovered + .iter() + .map(String::as_str) + .filter(|name| !EXPECTED_SCENARIOS.contains(name)) + .collect(); + + assert!( + missing.is_empty() && extra.is_empty(), + "Unexpected Claude diff-creation fixtures. Missing: {}. Extra: {}.", + missing.join(", "), + extra.join(", ") + ); + + EXPECTED_SCENARIOS + .iter() + .copied() + .filter(|name| discovered.contains(&name.to_string())) + .map(ToString::to_string) + .collect() +} + +fn load_fixture(name: &str) -> (Value, String) { + let dir = fixture_root().join(name); + let input_json = fs::read_to_string(dir.join("claude-post-tool-use.json")) + .expect("fixture input should exist"); + let expected_patch = fs::read_to_string(dir.join("expected.patch")) + .expect("fixture expected patch should exist"); + + let input: Value = + serde_json::from_str(&input_json).expect("fixture input should be valid JSON"); + + assert!( + input.get("session_id").and_then(|v| v.as_str()).is_some(), + "{name} fixture is missing a string session_id" + ); + + (input, expected_patch) +} + +#[test] +fn claude_derivation_golden_tests() { + for name in ordered_fixture_scenarios() { + let (input, expected_patch_text) = load_fixture(&name); + + let result = derive_claude_structured_patch( + "PostToolUse", + &input, + FIXED_TIME, + Some(FIXED_TOOL_VERSION), + ); + + match result { + ClaudeStructuredPatchDerivationResult::Derived(patch) => { + let session_id = input + .get("session_id") + .and_then(|v| v.as_str()) + .expect("session_id should be validated by load_fixture"); + assert_eq!( + patch.session_id, session_id, + "session_id mismatch for scenario {name}" + ); + assert_eq!(patch.time, FIXED_TIME, "time mismatch for scenario {name}"); + assert_eq!( + patch.tool_name, "claude", + "tool_name mismatch for scenario {name}" + ); + assert_eq!( + patch.tool_version, + Some(FIXED_TOOL_VERSION.to_string()), + "tool_version mismatch for scenario {name}" + ); + + let expected_patch = parse_patch(&expected_patch_text).unwrap_or_else(|e| { + panic!("expected patch should parse for scenario {name}: {e}") + }); + + assert_eq!( + patch.patch, expected_patch, + "patch mismatch for scenario {name}" + ); + + // model_id is omitted at the structured-patch layer; hunks should not carry it. + let all_hunks_empty_model_id = patch + .patch + .files + .iter() + .all(|file| file.hunks.iter().all(|hunk| hunk.model_id.is_none())); + assert!( + all_hunks_empty_model_id, + "model_id should be omitted for scenario {name}" + ); + } + ClaudeStructuredPatchDerivationResult::Skipped(reason) => { + panic!( + "Expected {name} fixture to derive a diff trace, but got skipped: {reason:?}" + ); + } + } + } +} diff --git a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts index f04448b5..e2fa484b 100644 --- a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts +++ b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts @@ -24,7 +24,7 @@ type ClaudePostToolUseFixture = { const fixtureRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - "../../../cli/src/services/patch/fixtures/diff_creation", + "../../../cli/src/services/structured_patch/fixtures", ); function discoverFixtureScenarios(): string[] { diff --git a/context/architecture.md b/context/architecture.md index 696ab807..7ce8f720 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -37,7 +37,7 @@ Current target renderer helper modules: - `config/pkl/generate.pkl` (single multi-file generation entrypoint) - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) - `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,pkl-parity,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, generated-output parity, and JS validation inventory) -- `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/patch/fixtures/diff_creation` for Claude agent-trace golden fixture coverage. +- `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden fixture coverage (Rust-owned since T02; the TypeScript Bun test remains present until T05). The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated Claude plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). @@ -125,7 +125,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, version, completion, help, patch, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` trait, `RuntimeCommandHandle` type alias, `CommandRegistry` struct, and `build_default_registry()` function for the command dispatch registry. Service-owned command modules now own the migrated runtime command structs and `RuntimeCommand` impls for help/help-text, version, completion, auth, config, setup, doctor, and hooks. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs Bun-discovered tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. -- The config-lib check source preserves repo-relative access to shared CLI patch fixtures: Nix copies a filtered repo-shaped source containing `config/lib/**` plus `cli/src/services/patch/fixtures/diff_creation`, then runs Bun/Biome from `config/lib/`. +- The config-lib check source preserves repo-relative access to shared CLI patch fixtures: Nix copies a filtered repo-shaped source containing `config/lib/**` plus `cli/src/services/structured_patch/fixtures`, then runs Bun/Biome from `config/lib/`. - `flake.nix` exposes release install/run surfaces as `packages.sce` (`packages.default = packages.sce`) plus `apps.sce` and `apps.default`, all targeting `${packages.sce}/bin/sce`; this keeps repo-local and remote flake run/install flows (`nix run .`, `nix run github:crocoder-dev/shared-context-engineering`, `nix profile install github:crocoder-dev/shared-context-engineering`) aligned to the same packaged CLI output. - `biome.json` at the repository root is the canonical Biome configuration for the current JS tooling slice and deliberately scopes coverage to `npm/**` plus the shared `config/lib/**` plugin package root while excluding package-local `node_modules/**`; `flake.nix` exposes Biome through the default dev shell rather than through package-local installs. - `cli/Cargo.toml` now keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`), git (`cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`), and local checkout (`cargo install --path cli --locked`) guidance. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index a9bbaac0..da2e1121 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -82,11 +82,12 @@ Public types consumed by the parser or load helpers have `#[allow(dead_code)]` r Patch reconstruction tests use deterministic fixture suites under `cli/src/services/patch/fixtures/`. - Existing suites remain intact (`average_age_reconstruction`, `hello_world_reconstruction`). -- `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by TypeScript derivation coverage. Each active golden scenario consumes: - - `claude-post-tool-use.json` containing a sanitized synthetic Claude `PostToolUse` payload shaped for `deriveClaudeDiffTracePayload(...)` +- `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by Rust derivation coverage, located at `cli/src/services/structured_patch/fixtures/`. Each active golden scenario consumes: + - `claude-post-tool-use.json` containing a sanitized synthetic Claude `PostToolUse` payload shaped for `derive_claude_structured_patch(...)` - `expected.patch` containing the expected unified diff output - No `input.json` sidecar is part of the active fixture contract for this suite. - Covered scenarios: simple/empty/no-newline/multiline Write create flows and single-hunk/multi-hunk/additions-only/deletions-only Edit flows + - Rust golden tests in `cli/src/services/structured_patch/tests.rs` discover and validate all scenarios at test time, failing on missing or extra fixtures. - The current tmp-hunks scenario is materialized as `text_file_lifecycle_reconstruction/` with: - `incremental_01.patch` .. `incremental_26.patch` reconstructed from `tmp_hunks/*-message.part.updated.json` in lexical filename order - `post_commit.patch` reconstructed from `tmp_hunks/*-post-commit.json` `input.head_patch_from_git` diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index 4fa59aa2..e4cf84ce 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -26,7 +26,7 @@ The module is not wired into `sce hooks diff-trace` yet. Current hook runtime st ## Test status -Golden fixture migration from `cli/src/services/patch/fixtures/diff_creation/` is deferred to T02. No generated helper tests are kept for this T01 slice. +Golden fixture coverage lives in `cli/src/services/structured_patch/tests.rs` as `claude_derivation_golden_tests`. The test discovers all scenarios under `cli/src/services/structured_patch/fixtures/`, validates the expected eight scenarios, and asserts derived `ParsedPatch` equality against `parse_patch(expected.patch)` with fixed time/tool-version inputs. No generated helper tests are kept for the T01 derivation slice. ## See also diff --git a/context/context-map.md b/context/context-map.md index ea2a68b4..a3a5af5c 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -55,7 +55,7 @@ Feature/domain context: - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus the implementation target for generated OpenCode enforcement, including config schema, argv-prefix matching, fixed preset catalog/messages, and precedence rules) - `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and the boundary that Claude uses generated `.claude/settings.json` + Bun runtime registration instead of OpenCode plugin manifests; Claude bash-policy enforcement has been removed from generated outputs) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode and Claude TypeScript agent-trace runtime behavior, including OpenCode `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name` plus required nullable `tool_version`, the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary, Claude `PostToolUse` Write/Edit derivation through `deriveClaudeDiffTracePayload(...)`, and derivation golden tests over `cli/src/services/patch/fixtures/diff_creation/`; Rust hook parsing and AgentTraceDb insertion persist parsed payload fields including optional/resolved `model_id`; `session.diff` event capture has been removed) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode and Claude TypeScript agent-trace runtime behavior, including OpenCode `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name` plus required nullable `tool_version`, the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary, Claude `PostToolUse` Write/Edit derivation through `deriveClaudeDiffTracePayload(...)` (TypeScript) and `derive_claude_structured_patch(...)` (Rust), and Rust-owned derivation golden tests over `cli/src/services/structured_patch/fixtures/`; Rust hook parsing and AgentTraceDb insertion persist parsed payload fields including optional/resolved `model_id`; `session.diff` event capture has been removed) - `context/sce/cli-first-install-channels-contract.md` (current first-wave `sce` install/distribution contract covering supported channels, canonical naming, `.version` release authority, and Nix-owned build policy) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, and the explicit non-default execution boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, and the current three-target Linux/macOS release workflow topology) diff --git a/context/glossary.md b/context/glossary.md index 4cc5a567..90378b40 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -13,7 +13,7 @@ - `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split shared `config/lib/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. - `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs Bun-discovered tests from the copied shared `config/lib/` package source (including bash-policy runtime tests and tracked agent-trace plugin tests), with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. -- `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/patch/fixtures/diff_creation` for Claude agent-trace golden tests. +- `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden tests (Rust-owned since T02; the TypeScript Bun test remains present until T05). - `config-lib shared package root`: Shared Bun/TypeScript package root at `config/lib/` for repository-owned OpenCode plugin support code. It owns `package.json`, `bun.lock`, and `tsconfig.json`, pins `@opencode-ai/plugin` to `1.15.4`, includes both `agent-trace-plugin/**/*.ts` and `bash-policy-plugin/**/*.ts` in strict TypeScript coverage, and excludes package-local `node_modules/` from both TypeScript and root Biome coverage. - `cli rust overlay toolchain`: Toolchain contract in root `flake.nix` that applies `rust-overlay.overlays.default`, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, uses that toolchain across both Crane package and check derivations, and keeps toolchain selection explicit rather than inheriting nixpkgs defaults. - `cli Crane package pipeline`: Current root-flake packaging path in `flake.nix` where `packages.sce` is built through `craneLib.buildDepsOnly` plus `craneLib.buildPackage` against a filtered repo-root source that preserves the Cargo tree and the embedded config/assets required by `cli/build.rs`. diff --git a/context/overview.md b/context/overview.md index 12c45468..8385fa87 100644 --- a/context/overview.md +++ b/context/overview.md @@ -31,7 +31,7 @@ The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in `cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`, `cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`, and local `cargo install --path cli --locked`. The published crate installs the `sce` binary. The crate also keeps `cargo clippy --manifest-path cli/Cargo.toml` warnings-denied through `cli/Cargo.toml` lint configuration, so an extra `-- -D warnings` flag is redundant. The repository-root flake is now the single Nix entrypoint for both repo tooling and CLI packaging/checks, so root-level `nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus six split JavaScript check derivations: `npm-bun-tests`, `npm-biome-check`, `npm-biome-format`, `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format`, without nested-flake indirection. The config-lib checks now consume `config/lib/` as the shared Bun/TypeScript package root for both `agent-trace-plugin/` and `bash-policy-plugin/`, with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`. For Cargo packaging/builds, the crate now compiles against a temporary `cli/assets/generated/` mirror prepared from canonical `config/` outputs during Nix builds and crates.io publish runs rather than from a committed crate-local snapshot. -Config-lib JS flake checks execute from `config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests consume `cli/src/services/patch/fixtures/diff_creation` through their normal repo-relative path. +Config-lib JS flake checks execute from `config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests consume `cli/src/services/structured_patch/fixtures` through their normal repo-relative path (Rust-owned since T02; the TypeScript Bun test remains present until T05). Local developer Nix tuning guidance now lives in `AGENTS.md`, including optional user-level `~/.config/nix/nix.conf` recommendations for `max-jobs` and `cores` plus an explicit system-level-only note for `auto-optimise-store`. The Pkl authoring layer owns generated OpenCode plugin registration for SCE-managed plugins: `config/pkl/base/opencode.pkl` defines the canonical plugin entries, `config/pkl/renderers/common.pkl` re-exports the shared plugin list for renderer use, and generated `config/.opencode/opencode.json` plus `config/automated/.opencode/opencode.json` register `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts` through OpenCode's `plugin` field. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. The current first-wave CLI install/distribution contract is now defined for `sce`: the active implemented channel set is repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the current implementation stage. Nix-managed build/release entrypoints are the source of truth for this rollout, npm consumes Nix-produced release artifacts, and repo-root `.version` is the canonical checked-in release version source that release packaging and downstream Cargo/npm publication must match. The shared release artifact foundation is now implemented through root-flake apps `release-artifacts` and `release-manifest`, which emit canonical `sce-v-.tar.gz` archives, SHA-256 checksum files, merged manifest outputs, and a detached `sce-v-release-manifest.json.sig` produced from a non-repo private signing key; the npm distribution surface is now implemented as a checked-in `npm/` launcher package plus root-flake `release-npm-package`, which packs `sce-v-npm.tgz`, refuses mismatched checked-in package metadata, and installs the native CLI by downloading the release manifest plus detached signature, verifying the manifest with the bundled npm public key, and only then checksum-verifying the matching GitHub release archive at npm `postinstall` time. GitHub Releases are the canonical publication surface for those release artifacts, while crates.io and npm registry publication are separate non-bumping publish stages under the approved release topology. GitHub CLI release automation now lives in dedicated `release-sce*.yml` workflows split by Linux, Linux ARM, and macOS ARM, and `.github/workflows/release-sce.yml` now orchestrates those three reusable platform lanes before assembling the signed release manifest, npm tarball, and GitHub release payload. The orchestrator now tags/releases the checked-in `.version` directly and rejects version mismatches instead of generating a new semver during workflow execution, `.github/workflows/publish-crates.yml` is the dedicated crates.io publish stage triggered from a published GitHub release or manual dispatch with the same `.version`/tag/Cargo parity checks and a clean temporary repo copy for Cargo packaging, and `release-agents.yml` remains Tessl-only. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index f0e71897..44d036c7 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -24,7 +24,7 @@ Planning interpretation: the last nine commits created and then refined a Claude - `sce hooks diff-trace` can accept Claude structured `PostToolUse` payloads and derive the same patch output currently covered by the `diff_creation` golden fixtures. - Existing OpenCode normalized `diff-trace` payloads remain accepted and behaviorally unchanged. - Claude TypeScript plugin source, generated plugin output, and Claude TypeScript golden tests are removed from the repo-owned Claude path. -- Golden fixture coverage for Claude diff derivation lives in Rust and validates the checked-in `cli/src/services/patch/fixtures/diff_creation/` scenarios. +- Golden fixture coverage for Claude diff derivation lives in Rust and validates the checked-in `cli/src/services/structured_patch/fixtures/` scenarios. - Generated output parity and full repo validation pass. ## Constraints and non-goals @@ -46,12 +46,13 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run the narrow Rust tests added for the derivation helper via `nix develop -c sh -c 'cd cli && cargo test claude'` if a narrow test target exists; otherwise use the narrowest relevant `cargo test` selector. Final full validation remains T07. - Completion evidence (2026-06-10): Added synchronous Rust `structured_patch` service module; Claude `PostToolUse` `Write` and `Edit` structured payload derivation returns `ParsedPatch`-backed `ClaudeStructuredPatch` results with deterministic skip reasons and fixed time/tool-version inputs. Generated helper tests were removed after review; golden fixture coverage remains deferred to T02. `nix flake check` passed. `nix run .#pkl-check-generated` passed. Direct narrow `cargo test claude` was not run because the repo bash policy blocks direct Cargo test commands in favor of `nix flake check`. -- [ ] T02: `Move Claude diff-creation golden tests to Rust` (status:todo) +- [x] T02: `Move Claude diff-creation golden tests to Rust` (status:done) - Task ID: T02 - - Goal: Recreate the current Claude derivation golden coverage in Rust against `cli/src/services/patch/fixtures/diff_creation/`. + - Goal: Recreate the current Claude derivation golden coverage in Rust against `cli/src/services/structured_patch/fixtures/`. - Boundaries (in/out of scope): In - Rust tests that discover/validate the expected eight fixture scenarios and assert derived patch equality using `claude-post-tool-use.json` plus `expected.patch`. Out - deleting TypeScript tests/source, modifying fixture contents except for necessary fixture-contract corrections, changing OpenCode tests. - Done when: Rust tests fail on missing/extra scenarios, use fixed time/tool-version inputs, assert `sessionID`, `tool_name="claude"`, nullable/omitted `model_id` behavior as appropriate, and exact diff output for each golden fixture. - Verification notes (commands or checks): Run the narrow Rust golden test selector through Nix, for example `nix develop -c sh -c 'cd cli && cargo test claude_derivation'` once test names are known. + - Completion evidence (2026-06-10): Added `cli/src/services/structured_patch/tests.rs` with runtime fixture discovery, missing/extra scenario validation, and `claude_derivation_golden_tests` asserting all eight `diff_creation/` scenarios against `derive_claude_structured_patch` with fixed time/tool-version inputs. `nix flake check` passed. `nix run .#pkl-check-generated` passed. - [ ] T03: `Teach sce hooks diff-trace to accept Claude structured payloads` (status:todo) - Task ID: T03 diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index c53b8924..3e855da3 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -47,7 +47,7 @@ Otherwise, the helper returns `undefined`. ## Claude derivation golden tests -- `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts` tests the exported `deriveClaudeDiffTracePayload(...)` seam only; private diff-render helpers remain unexported and untested directly. -- The test dynamically discovers the checked-in `cli/src/services/patch/fixtures/diff_creation/` scenario directories, validates the expected eight-scenario set, then loads each `claude-post-tool-use.json` plus `expected.patch` pair. -- Each scenario calls `deriveClaudeDiffTracePayload(...)` with fixed time/tool-version inputs and asserts derived status, session ID, time, `tool_name="claude"`, tool version, exact golden diff, and no emitted `model_id`. -- The tests are discoverable from both `config/lib/agent-trace-plugin` and the shared `config/lib` Bun package root via `bun test`. +- Rust golden tests in `cli/src/services/structured_patch/tests.rs` (`claude_derivation_golden_tests`) own the Claude derivation fixture coverage. +- The test dynamically discovers the checked-in `cli/src/services/structured_patch/fixtures/` scenario directories, validates the expected eight-scenario set, then loads each `claude-post-tool-use.json` plus `expected.patch` pair. +- Each scenario calls `derive_claude_structured_patch(...)` with fixed time/tool-version inputs and asserts derived status, session ID, time, `tool_name="claude"`, tool version, exact golden diff parsed via `parse_patch`, and no emitted `model_id`. +- The TypeScript `deriveClaudeDiffTracePayload(...)` seam and its Bun test remain present until T05 removes the Claude TypeScript plugin source. diff --git a/flake.nix b/flake.nix index 98a1079e..0ba34ac3 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,7 @@ (pkgs.lib.fileset.maybeMissing ./cli/src/services/default_paths.rs) (pkgs.lib.fileset.maybeMissing ./cli/src/services/agent_trace/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/patch/fixtures) + (pkgs.lib.fileset.maybeMissing ./cli/src/services/structured_patch/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/migrations) ./config (pkgs.lib.fileset.maybeMissing ./cli/assets/hooks) @@ -127,7 +128,7 @@ ./config/lib/bash-policy-plugin/bash-policy/runtime.ts ./config/lib/bash-policy-plugin/bash-policy-runtime.test.ts ./config/lib/bash-policy-plugin/opencode-bash-policy-plugin.ts - (pkgs.lib.fileset.maybeMissing ./cli/src/services/patch/fixtures/diff_creation) + (pkgs.lib.fileset.maybeMissing ./cli/src/services/structured_patch/fixtures) ]; }; From ddc090e527eedf932cb809389527ed6e71caded2 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 15:23:55 +0200 Subject: [PATCH 12/18] agent-trace-db: Add payload_type discriminator to diff_traces Introduce migration 009_add_diff_traces_payload_type to add a payload_type column (TEXT NOT NULL DEFAULT 'patch') to the diff_traces table. This enables generic storage of source payloads with explicit type discrimination: patch for OpenCode unified-diff payloads and structured for Claude PostToolUse structured payloads. Update typed insert and query structs (DiffTraceInsert, DiffTracePatchRow, ParsedDiffTracePatch) to carry payload_type, and add PAYLOAD_TYPE_PATCH and PAYLOAD_TYPE_STRUCTURED constants. Wire PAYLOAD_TYPE_PATCH through the existing sce hooks diff-trace hook runtime to preserve current OpenCode behavior. Update recent-diff-trace query SQL, row parsing, and tests to include the new column. Co-authored-by: SCE --- .../009_add_diff_traces_payload_type.sql | 1 + cli/src/services/agent_trace_db/mod.rs | 41 +++++++++++- cli/src/services/hooks/mod.rs | 4 +- context/cli/structured-patch-service.md | 2 +- context/context-map.md | 4 +- context/glossary.md | 7 +- context/plans/claude-rust-diff-trace.md | 64 +++++++++++++------ context/sce/agent-trace-db.md | 14 ++-- 8 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 cli/migrations/agent-trace/009_add_diff_traces_payload_type.sql diff --git a/cli/migrations/agent-trace/009_add_diff_traces_payload_type.sql b/cli/migrations/agent-trace/009_add_diff_traces_payload_type.sql new file mode 100644 index 00000000..a893fe95 --- /dev/null +++ b/cli/migrations/agent-trace/009_add_diff_traces_payload_type.sql @@ -0,0 +1 @@ +ALTER TABLE diff_traces ADD COLUMN payload_type TEXT NOT NULL DEFAULT 'patch'; \ No newline at end of file diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 19e7aa68..4411b1a3 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -30,6 +30,8 @@ const CREATE_AGENT_TRACES_REMOTE_URL_INDEX_MIGRATION: &str = include_str!( ); const CREATE_SESSION_MODELS_MIGRATION: &str = include_str!("../../../migrations/agent-trace/008_create_session_models.sql"); +const ADD_DIFF_TRACES_PAYLOAD_TYPE_MIGRATION: &str = + include_str!("../../../migrations/agent-trace/009_add_diff_traces_payload_type.sql"); const AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ ("001_create_diff_traces", CREATE_DIFF_TRACES_MIGRATION), @@ -55,15 +57,27 @@ const AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ CREATE_AGENT_TRACES_REMOTE_URL_INDEX_MIGRATION, ), ("008_create_session_models", CREATE_SESSION_MODELS_MIGRATION), + ( + "009_add_diff_traces_payload_type", + ADD_DIFF_TRACES_PAYLOAD_TYPE_MIGRATION, + ), ]; +/// Payload type discriminator for diff trace source payloads. +/// +/// `OpenCode` normalized diff-trace payloads use [`PAYLOAD_TYPE_PATCH`]. +/// `Claude` structured `PostToolUse` payloads use [`PAYLOAD_TYPE_STRUCTURED`]. +pub const PAYLOAD_TYPE_PATCH: &str = "patch"; +#[allow(dead_code)] +pub const PAYLOAD_TYPE_STRUCTURED: &str = "structured"; + /// Parameterized SQL for inserting a captured diff trace payload. pub const INSERT_DIFF_TRACE_SQL: &str = - "INSERT INTO diff_traces (time_ms, session_id, patch, model_id, tool_name, tool_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; + "INSERT INTO diff_traces (time_ms, session_id, patch, model_id, tool_name, tool_version, payload_type) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"; /// Parameterized SQL for retrieving recent captured diff trace patches. pub const SELECT_RECENT_DIFF_TRACE_PATCHES_SQL: &str = - "SELECT id, time_ms, session_id, patch, model_id, tool_name, tool_version + "SELECT id, time_ms, session_id, patch, model_id, tool_name, tool_version, payload_type FROM diff_traces WHERE time_ms >= ?1 AND time_ms <= ?2 ORDER BY time_ms ASC, id ASC"; @@ -138,6 +152,7 @@ pub struct DiffTraceInsert<'a> { pub model_id: Option<&'a str>, pub tool_name: &'a str, pub tool_version: Option<&'a str>, + pub payload_type: &'a str, } /// Session model attribution payload to upsert into the agent trace database. @@ -170,6 +185,7 @@ pub struct DiffTracePatchRow { pub model_id: Option, pub tool_name: Option, pub tool_version: Option, + pub payload_type: String, } /// Parsed recent diff trace patch ready for comparison flows. @@ -181,6 +197,7 @@ pub struct ParsedDiffTracePatch { pub patch: ParsedPatch, pub tool_name: Option, pub tool_version: Option, + pub payload_type: String, } /// Deterministic skipped-row report for invalid recent diff trace patches. @@ -286,6 +303,7 @@ fn insert_diff_trace_with(db: &TursoDb, input: DiffTraceInsert<'_> input.model_id, input.tool_name, input.tool_version, + input.payload_type, ), ) } @@ -399,6 +417,9 @@ fn diff_trace_patch_row_from_turso(row: &turso::Row) -> Result) -> RecentDif patch, tool_name: row.tool_name, tool_version: row.tool_version, + payload_type: row.payload_type, }); } Err(error) => skipped.push(SkippedDiffTracePatch { @@ -526,6 +548,7 @@ mod tests { model_id: Some("test-provider/test-model"), tool_name: "opencode", tool_version: Some("1.2.3"), + payload_type: PAYLOAD_TYPE_PATCH, }, ) .expect("diff trace insert should succeed"); @@ -610,6 +633,19 @@ mod tests { (Some("opencode"), Some("1.2.3")), ] ); + assert_eq!( + result + .patches + .iter() + .map(|patch| patch.payload_type.as_str()) + .collect::>(), + vec![ + PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_PATCH + ] + ); assert_eq!( result .patches @@ -680,6 +716,7 @@ mod tests { "006_add_agent_traces_remote_url", "007_create_agent_traces_remote_url_index", "008_create_session_models", + "009_add_diff_traces_payload_type", ] ); diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index b8e2927c..70fb4674 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -16,7 +16,7 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::{ AgentTraceDb, AgentTraceInsert, DiffTraceInsert, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, SessionModelUpsert, + RecentDiffTracePatches, SessionModelUpsert, PAYLOAD_TYPE_PATCH, }; use crate::services::observability::traits::Logger; use crate::services::patch::{ @@ -511,6 +511,7 @@ where model_id, tool_name: &payload.tool_name, tool_version: payload.tool_version.as_deref(), + payload_type: PAYLOAD_TYPE_PATCH, }) } @@ -1325,6 +1326,7 @@ mod tests { patch: valid_patch("src/lib.rs", "shared line"), tool_name: Some(String::from("opencode")), tool_version: Some(String::from("1.2.3")), + payload_type: String::from(PAYLOAD_TYPE_PATCH), }], skipped: vec![SkippedDiffTracePatch { id: 8, diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index e4cf84ce..bedf1c84 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -22,7 +22,7 @@ ## Runtime wiring status -The module is not wired into `sce hooks diff-trace` yet. Current hook runtime still accepts normalized diff-trace JSON with raw diff text. Runtime integration is planned in `context/plans/claude-rust-diff-trace.md` T03. +The module is not wired into `sce hooks diff-trace` yet. Current hook runtime still accepts normalized diff-trace JSON with raw diff text. The `diff_traces` table now supports a `payload_type` discriminator (`patch` for `OpenCode` unified-diff payloads, `structured` for `Claude` `PostToolUse` payloads) so structured payloads can be persisted and later parsed through `structured_patch.rs` during post-commit processing. Runtime intake wiring is planned in T04; post-commit parsing dispatch is planned in T05. ## Test status diff --git a/context/context-map.md b/context/context-map.md index a3a5af5c..8997c1f6 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -13,7 +13,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, set operations, and checked-in reconstruction/diff-creation fixture suites for `cli/src/services/patch.rs`; covers touched-line parsing for `Index:` SVN-style and `diff --git` git-style formats, `ParseError`/`PatchLoadError` diagnostics, `intersect_patches` target-shaped overlap with `model_id` provenance inheritance, `combine_patches` later-wins conflict resolution, active post-commit hook consumption, and the Claude derivation `diff_creation/` golden fixtures) -- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; not wired into hook runtime until the planned T03 integration) +- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; not wired into hook runtime until the planned T04/T05 integration) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) @@ -44,7 +44,7 @@ Feature/domain context: - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) - `context/sce/local-db.md` (implemented `cli/src/services/local_db/mod.rs` local database spec with `LocalDb = TursoDb`, canonical local DB path resolution, zero local migrations, and inherited blocking `execute`/`query` methods using the shared Turso adapter) - `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, `EncryptedTursoDb` encrypted constructor path with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before keyring-backed encryption key resolution via `encryption_key::get_or_create_encryption_key()`, strict `aegis256` selection via Turso `EncryptionOpts`, shared internal `TursoConnectionCore` operation/migration path for both public adapters, stable `OnceLock` plus atomic retry guard for credential-store default registration without mutex poisoning, platform-specific credential-store remediation mentioning the env fallback, per-database `__sce_migrations` tracking, generic embedded migration execution, and current concrete wrappers for `LocalDb`, `AgentTraceDb`, and encrypted `AuthDb`) -- `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with canonical `/sce/agent-trace.db` path, ordered `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, and `session_models` migrations applied through shared migration metadata, typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, built `agent_traces` rows with `agent_trace_id` plus schema-validated trace JSON containing range `content_hash`, and durable session model attribution keyed by `(tool_name, session_id)`, inclusive bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, and active hook writers for `diff_traces` intake, `session_models` upsert via `session-model`, plus post-commit intersection/agent-trace persistence) +- `context/sce/agent-trace-db.md` (implemented `cli/src/services/agent_trace_db/mod.rs` Agent Trace database wrapper with canonical `/sce/agent-trace.db` path, ordered `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, and `session_models` migrations applied through shared migration metadata, typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata + `payload_type` discriminator, post-commit intersection rows, built `agent_traces` rows with `agent_trace_id` plus schema-validated trace JSON containing range `content_hash`, and durable session model attribution keyed by `(tool_name, session_id)`, inclusive bounded chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, and active hook writers for `diff_traces` intake, `session_models` upsert via `session-model`, plus post-commit intersection/agent-trace persistence) - `context/sce/auth-db.md` (current encrypted auth DB foundation: canonical `/sce/auth.db` path resolver, `AuthDb = EncryptedTursoDb` wrapper, encryption key resolution from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before OS keyring fallback with no plaintext mode, platform-specific missing/unavailable credential-store remediation that points headless/CI users to the env fallback, baseline migration 001 creating `auth_credentials` without `user_id`, with `updated_at`, and 002 creating the `updated_at` auto-refresh trigger instead of a `user_id` index, and `AuthDbLifecycle` provider registered in the shared lifecycle catalog) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) diff --git a/context/glossary.md b/context/glossary.md index 90378b40..5573830f 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -34,11 +34,12 @@ - `encrypted Turso adapter`: Generic adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` (connection, tokio runtime bridge, spec typing). Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, runs embedded migrations after connect, and exposes synchronous `execute`, `query`, `query_map`, and `run_migrations` helpers with `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. -- `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..008`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)`, with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). -- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; hook-runtime wiring is deferred to the planned T03 integration. +- `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..009`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), `session_models` keyed by `(tool_name, session_id)`, and `diff_traces.payload_type` discriminator (`patch`/`structured` with default `'patch'`), with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata + `payload_type`, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). +- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; hook-runtime wiring is deferred to the planned T04/T05 integration. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. -- `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, and nullable `tool_version` for parameterized writes to the `diff_traces` table. +- `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. +- `diff_traces payload_type discriminator`: `TEXT NOT NULL DEFAULT 'patch'` column in `diff_traces` added by migration `009_add_diff_traces_payload_type`; values are `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff source payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured source payloads; existing rows default to `"patch"` for backward compatibility. - `DbSpec`: Service-specific database metadata trait in `cli/src/services/db/mod.rs` that supplies a diagnostic database name, canonical path resolver, and ordered embedded migration list for `TursoDb`. - `TursoDb`: Generic unencrypted Turso database adapter in `cli/src/services/db/mod.rs`; owns parent-directory creation and Turso local open/connect flow, then delegates synchronous `execute()`/`query()`/`query_map()` wrappers and migration execution to the shared internal `TursoConnectionCore` for a `DbSpec` implementation. - `TursoConnectionCore`: Internal shared operation core in `cli/src/services/db/mod.rs` used by both `TursoDb` and `EncryptedTursoDb`; owns tokio current-thread runtime bridging, synchronous Turso operation wrappers, per-database `__sce_migrations` metadata, and generic embedded migration execution. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index 44d036c7..80be85d6 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -21,21 +21,28 @@ Planning interpretation: the last nine commits created and then refined a Claude ## Success criteria - Generated Claude settings call `sce hooks session-model` and `sce hooks diff-trace` directly; they no longer execute Bun or `.claude/plugins/sce-agent-trace.ts`. -- `sce hooks diff-trace` can accept Claude structured `PostToolUse` payloads and derive the same patch output currently covered by the `diff_creation` golden fixtures. -- Existing OpenCode normalized `diff-trace` payloads remain accepted and behaviorally unchanged. +- AgentTraceDb stores diff-trace source payloads behind a generic payload column plus a payload-type discriminator, with OpenCode rows marked as patch payloads and Claude rows marked as structured payloads. +- `sce hooks diff-trace` persists Claude structured `PostToolUse` payload JSON without first rendering it into a patchset; post-commit processing derives `ParsedPatch` from the stored structured JSON through `structured_patch.rs`. +- Existing OpenCode normalized `diff-trace` payloads remain accepted and behaviorally unchanged, except for being stored through the generic payload/discriminator schema and parsed from that representation during post-commit processing. - Claude TypeScript plugin source, generated plugin output, and Claude TypeScript golden tests are removed from the repo-owned Claude path. - Golden fixture coverage for Claude diff derivation lives in Rust and validates the checked-in `cli/src/services/structured_patch/fixtures/` scenarios. - Generated output parity and full repo validation pass. ## Constraints and non-goals -- In scope: Rust hook intake/derivation, Rust tests, Pkl-generated Claude settings, generated output updates, context sync. +- In scope: AgentTraceDb diff-trace storage migration, Rust hook intake/derivation, Rust tests, Pkl-generated Claude settings, generated output updates, context sync. - In scope: removing Claude-specific TypeScript plugin source/tests and generated `.claude/plugins` output. - Out of scope: changing OpenCode TypeScript plugin behavior or removing OpenCode TypeScript runtime code. -- Out of scope: changing post-commit Agent Trace payload semantics, AgentTraceDb schema, or OpenCode plugin registration. +- Out of scope: changing post-commit Agent Trace payload semantics, AgentTraceDb schema beyond the `diff_traces` typed source-payload migration, or OpenCode plugin registration. - Out of scope: adding a new external dependency unless implementation proves the existing Rust stack cannot parse the structured Claude payload safely. - Preserve one-task/one-atomic-commit slicing; each executable task below should land independently. +## Assumptions + +- The generic diff-trace persisted payload uses a discriminator with values equivalent to `patch` for OpenCode unified patch payloads and `structured` for Claude structured hook payloads. +- Claude structured rows should mirror OpenCode row behavior: persist the source payload at `sce hooks diff-trace` intake, then convert to `ParsedPatch` only during post-commit recent-diff-trace processing. +- `cli/src/services/structured_patch.rs` remains the Rust owner for converting Claude structured payload JSON into `ParsedPatch`. + ## Task stack - [x] T01: `Add Rust Claude hook payload derivation model` (status:done) @@ -43,7 +50,7 @@ Planning interpretation: the last nine commits created and then refined a Claude - Goal: Add Rust data models and pure derivation helpers that convert supported Claude `PostToolUse` structured payloads into the existing normalized diff-trace shape. - Boundaries (in/out of scope): In - Rust-only parsing/normalization helpers for Claude `Write` create and `Edit` structured-patch payloads, status/skip reasons matching the current supported cases, no CLI routing changes. Out - generated settings changes, TypeScript deletion, DB persistence changes, OpenCode flow changes. - Done when: Rust exposes a testable pure function that accepts event name + JSON payload + fixed time/tool version inputs and returns derived `{ sessionID, diff, time, tool_name="claude", tool_version }` or deterministic skip/error results for unsupported payloads. - - Verification notes (commands or checks): Run the narrow Rust tests added for the derivation helper via `nix develop -c sh -c 'cd cli && cargo test claude'` if a narrow test target exists; otherwise use the narrowest relevant `cargo test` selector. Final full validation remains T07. + - Verification notes (commands or checks): Run the narrow Rust tests added for the derivation helper via `nix develop -c sh -c 'cd cli && cargo test claude'` if a narrow test target exists; otherwise use the narrowest relevant `cargo test` selector. Final full validation remains T09. - Completion evidence (2026-06-10): Added synchronous Rust `structured_patch` service module; Claude `PostToolUse` `Write` and `Edit` structured payload derivation returns `ParsedPatch`-backed `ClaudeStructuredPatch` results with deterministic skip reasons and fixed time/tool-version inputs. Generated helper tests were removed after review; golden fixture coverage remains deferred to T02. `nix flake check` passed. `nix run .#pkl-check-generated` passed. Direct narrow `cargo test claude` was not run because the repo bash policy blocks direct Cargo test commands in favor of `nix flake check`. - [x] T02: `Move Claude diff-creation golden tests to Rust` (status:done) @@ -54,41 +61,56 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run the narrow Rust golden test selector through Nix, for example `nix develop -c sh -c 'cd cli && cargo test claude_derivation'` once test names are known. - Completion evidence (2026-06-10): Added `cli/src/services/structured_patch/tests.rs` with runtime fixture discovery, missing/extra scenario validation, and `claude_derivation_golden_tests` asserting all eight `diff_creation/` scenarios against `derive_claude_structured_patch` with fixed time/tool-version inputs. `nix flake check` passed. `nix run .#pkl-check-generated` passed. -- [ ] T03: `Teach sce hooks diff-trace to accept Claude structured payloads` (status:todo) +- [x] T03: `Migrate diff_traces to typed generic payload storage` (status:done) - Task ID: T03 - - Goal: Extend the Rust `sce hooks diff-trace` STDIN intake so Claude structured `PostToolUse` payloads are derived in Rust and then pass through the existing diff-trace persistence path. - - Boundaries (in/out of scope): In - payload classification, validation errors/skips, derivation-to-existing `DiffTracePayload` adapter, tests for Claude structured payload runtime path and existing normalized payload compatibility. Out - new DB schema, post-commit flow changes, OpenCode plugin changes, generated settings changes. - - Done when: `diff-trace` accepts both the existing normalized payload and the new Claude structured payload; Claude unsupported/no-diff cases produce deterministic success/no-op or validation behavior consistent with current hook semantics; OpenCode normalized payload tests still pass unchanged. - - Verification notes (commands or checks): Run focused hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test hooks'`; include a targeted exact test when available. + - Goal: Replace patch-only diff-trace persistence with a generic source-payload column plus payload-type discriminator while preserving existing OpenCode rows and query behavior. + - Boundaries (in/out of scope): In - AgentTraceDb migration(s), typed insert/query structs, constants/enums for `patch` and `structured` discriminator values, backward-compatible handling for existing `patch` data if required by current migrations/tests. Out - Claude hook intake, post-commit structured parsing, generated settings changes, OpenCode plugin changes. + - Done when: New diff-trace inserts can persist payload text with an explicit type; existing OpenCode patch payloads are stored/read as `patch`; recent-diff-trace query code exposes enough typed information for later parsing into `ParsedPatch`. + - Verification notes (commands or checks): Run focused AgentTraceDb tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test agent_trace_db'`; include migration/backward-compatibility test coverage where the current DB test harness supports it. + - Completion evidence (2026-06-10): Added migration `009_add_diff_traces_payload_type.sql` adding `payload_type TEXT NOT NULL DEFAULT 'patch'` to `diff_traces`. Added `PAYLOAD_TYPE_PATCH` and `PAYLOAD_TYPE_STRUCTURED` constants to `agent_trace_db/mod.rs`. Updated `DiffTraceInsert`, `DiffTracePatchRow`, `ParsedDiffTracePatch` to carry `payload_type`. Updated `INSERT_DIFF_TRACE_SQL` and `SELECT_RECENT_DIFF_TRACE_PATCHES_SQL` to include `payload_type`. Updated `insert_diff_trace_with`, `diff_trace_patch_row_from_turso`, and `parse_recent_diff_trace_patch_rows` for the new column. Updated `hooks/mod.rs` to pass `PAYLOAD_TYPE_PATCH` for existing OpenCode diff-trace flow. Updated baseline migration test to expect 9 migrations. Added `payload_type` assertion to existing diff-trace query test. `nix flake check` passed. `nix run .#pkl-check-generated` passed. -- [ ] T04: `Render Claude settings with direct sce hook commands` (status:todo) +- [ ] T04: `Persist Claude structured diff-trace source payloads` (status:todo) - Task ID: T04 + - Goal: Extend `sce hooks diff-trace` STDIN intake so Claude structured `PostToolUse` payload JSON is classified and persisted as a structured source payload without converting it to a patchset at insert time. + - Boundaries (in/out of scope): In - payload classification, validation errors/skips for unsupported/no-diff Claude events, insert adapter to the generic payload schema, tests for Claude structured payload intake and existing OpenCode normalized payload compatibility. Out - post-commit conversion to `ParsedPatch`, generated settings changes, OpenCode plugin changes. + - Done when: `diff-trace` accepts existing OpenCode normalized payloads as `patch` payloads and Claude supported structured payloads as `structured` payloads; Claude unsupported/no-diff cases produce deterministic success/no-op or validation behavior consistent with current hook semantics; no Claude row is rendered to unified-diff text before DB persistence. + - Verification notes (commands or checks): Run focused hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test hooks'`; include a targeted exact test when available. + +- [ ] T05: `Parse typed diff-trace payloads during post-commit processing` (status:todo) + - Task ID: T05 + - Goal: Update post-commit recent-diff-trace processing so typed persisted payloads are converted into `ParsedPatch` at read/processing time. + - Boundaries (in/out of scope): In - parser dispatch for `payload_type="patch"` through existing patch parsing, parser dispatch for `payload_type="structured"` through `structured_patch.rs`, malformed-row skip accounting, model/tool metadata preservation, tests covering mixed OpenCode+Claude rows. Out - DB schema changes beyond T03, hook settings generation, Agent Trace output schema changes. + - Done when: Post-commit combines/intersects OpenCode patch rows and Claude structured rows through the same `ParsedPatch` pipeline; structured Claude rows derive the same patch output as Rust golden fixtures; malformed or unsupported stored payloads are skipped/reportable without breaking valid rows. + - Verification notes (commands or checks): Run focused post-commit/hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test post_commit'` or the narrowest matching selector once test names are known. + +- [ ] T06: `Render Claude settings with direct sce hook commands` (status:todo) + - Task ID: T06 - Goal: Update canonical Pkl-generated Claude settings so Claude invokes `sce hooks session-model` and `sce hooks diff-trace` directly instead of running Bun against `.claude/plugins/sce-agent-trace.ts`. - Boundaries (in/out of scope): In - `config/pkl/renderers/claude-content.pkl` settings command definitions and regenerated `config/.claude/settings.json` / repo-root `.claude/settings.json` outputs. Out - OpenCode renderer/plugin registration, agent/skill content changes unrelated to settings, manual edits to generated outputs without source updates. - Done when: Generated Claude settings contain no `.claude/plugins/sce-agent-trace.ts` or `bun` hook invocation for agent tracing, and route `SessionStart` to `sce hooks session-model` while routing matched `PostToolUse` to `sce hooks diff-trace` with Claude hook payload on STDIN according to Claude hook command behavior. - Verification notes (commands or checks): Run `nix develop -c pkl eval -m . config/pkl/generate.pkl` after source edits, then `nix run .#pkl-check-generated`. -- [ ] T05: `Remove Claude TypeScript plugin source and generated outputs` (status:todo) - - Task ID: T05 +- [ ] T07: `Remove Claude TypeScript plugin source and generated outputs` (status:todo) + - Task ID: T07 - Goal: Delete the now-obsolete Claude TypeScript agent-trace runtime and its TypeScript golden tests while preserving OpenCode TypeScript plugin/runtime code. - Boundaries (in/out of scope): In - remove `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts`, its Bun test, generated `config/.claude/plugins/sce-agent-trace.ts`, generated root `.claude/plugins/sce-agent-trace.ts`, and references that assume a Claude plugin path exists. Out - `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts`, OpenCode generated plugins, bash-policy code. - Done when: No repo-owned Claude `.claude/plugins` agent-trace TypeScript remains; config-lib package/test configuration no longer expects the deleted Claude test; generated output parity is clean after regeneration. - - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T07 full validation. + - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T09 full validation. -- [ ] T06: `Sync current-state context for Rust-owned Claude tracing` (status:todo) - - Task ID: T06 +- [ ] T08: `Sync current-state context for Rust-owned Claude tracing` (status:todo) + - Task ID: T08 - Goal: Update durable context to describe the new Rust-owned Claude derivation boundary and removal of Claude TypeScript plugin runtime. - Boundaries (in/out of scope): In - focused updates to `context/sce/opencode-agent-trace-plugin-runtime.md`, `context/sce/agent-trace-hooks-command-routing.md`, `context/cli/patch-service.md`, `context/context-map.md`, `context/overview.md`, and glossary/architecture entries if needed. Out - historical narration beyond current-state facts, unrelated context cleanup. - - Done when: Context says OpenCode still uses TypeScript normalized diff traces, Claude settings call `sce hooks` directly, Rust derives Claude structured patches, and golden tests are Rust-owned. + - Done when: Context says OpenCode still uses TypeScript normalized diff traces, diff-trace storage uses typed source payloads, Claude settings call `sce hooks` directly, Rust derives Claude structured patches during post-commit processing, and golden tests are Rust-owned. - Verification notes (commands or checks): Review context references for stale `.claude/plugins/sce-agent-trace.ts`, Claude TypeScript golden test, and shared TypeScript-runtime-to-Rust boundary claims. -- [ ] T07: `Validate and clean up Claude Rust diff-trace migration` (status:todo) - - Task ID: T07 +- [ ] T09: `Validate and clean up Claude Rust diff-trace migration` (status:todo) + - Task ID: T09 - Goal: Run final validation, remove temporary scaffolding, and record plan completion evidence. - Boundaries (in/out of scope): In - full repo validation, generated-output parity, checking for stale Claude TypeScript references, updating this plan with validation evidence. Out - new feature work or unrelated refactors discovered during validation. - Done when: `nix run .#pkl-check-generated` and `nix flake check` pass; no stale Claude plugin TypeScript files/references remain except intentional historical references; plan status/evidence is updated. - - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; targeted search for `.claude/plugins/sce-agent-trace.ts`, `deriveClaudeDiffTracePayload`, and Claude TypeScript golden-test references. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; targeted search for `.claude/plugins/sce-agent-trace.ts`, `deriveClaudeDiffTracePayload`, and Claude TypeScript golden-test references; targeted search/review that Claude structured payload rows are not rendered into patchsets before persistence. ## Open questions -- None blocking. User clarified that Claude derivation should happen fully in Rust, Claude TypeScript should be removed, OpenCode TypeScript should remain, and generated Claude settings should call `sce hooks` directly. +- None blocking. User clarified that Claude derivation should happen fully in Rust, Claude TypeScript should be removed, OpenCode TypeScript should remain, generated Claude settings should call `sce hooks` directly, and AgentTraceDb should persist generic typed source payloads so Claude structured payloads are converted to `ParsedPatch` during post-commit processing rather than insert-time patchset rendering. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index e79c72ff..412c2340 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -10,7 +10,8 @@ pub type AgentTraceDb = TursoDb; - `AgentTraceDbSpec`: `DbSpec` implementation for Agent Trace persistence. - `AgentTraceDb`: type alias for `TursoDb`. -- `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: Option<&'a str>`, `tool_name: &'a str`, and nullable `tool_version: Option<&'a str>`. +- `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: Option<&'a str>`, `tool_name: &'a str`, nullable `tool_version: Option<&'a str>`, and `payload_type: &'a str` (using `PAYLOAD_TYPE_PATCH` or `PAYLOAD_TYPE_STRUCTURED` constants). +- `PAYLOAD_TYPE_PATCH` / `PAYLOAD_TYPE_STRUCTURED`: string constants (`"patch"` / `"structured"`) for the `diff_traces.payload_type` discriminator column; `OpenCode` normalized diff-trace payloads use `patch`, `Claude` structured `PostToolUse` payloads use `structured`. - `insert_diff_trace()`: domain-specific insert helper using parameterized SQL. - `RecentDiffTracePatches`: parsed recent `diff_traces` query result containing valid parsed patches plus skipped-row reports. - `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)`: chronological `diff_traces` read helper for rows in the inclusive window `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`; parses raw patch text through `parse_patch` and skips malformed rows without failing the query. @@ -45,6 +46,7 @@ The Agent Trace DB path is resolved from the shared default-path catalog: - `006_add_agent_traces_vcs_remote_url.sql` (historical filename; migration ID `006_add_agent_traces_remote_url` adds the `remote_url` column) - `007_create_agent_traces_vcs_remote_url_index.sql` (historical filename; migration ID `007_create_agent_traces_remote_url_index` creates `idx_agent_traces_remote_url`) - `008_create_session_models.sql` +- `009_add_diff_traces_payload_type.sql` (adds `payload_type TEXT NOT NULL DEFAULT 'patch'` to `diff_traces`) The shared `TursoDb` runner records applied IDs in the database-local `__sce_migrations` table. Existing Agent Trace DB files without metadata are brought forward by re-applying the idempotent migration set and recording each ID, so rerunning `sce setup` / `AgentTraceDb::new()` applies later Agent Trace migrations to an already-created `~/.local/state/sce/agent-trace.db`. @@ -59,6 +61,10 @@ The `diff_traces` baseline migration creates: - `tool_name TEXT` - `tool_version TEXT` +Migration `009_add_diff_traces_payload_type` adds: + +- `payload_type TEXT NOT NULL DEFAULT 'patch'` — discriminator for source payload format; `patch` for `OpenCode` unified-diff payloads, `structured` for `Claude` `PostToolUse` structured payloads. + The `post_commit_patch_intersections` baseline migration creates: - `id INTEGER PRIMARY KEY` @@ -114,7 +120,7 @@ Lookup indexes created by the baseline migration set: `sce hooks diff-trace` is the current runtime writer for `diff_traces`. - The hook path validates required STDIN `{ sessionID, diff, time, tool_name, tool_version }` before persistence, with `model_id` accepted as optional (absent or `null`). When `model_id` is absent, Rust resolves it from `session_models` by `(tool_name, session_id)`. If no matching session model row exists, the hook returns success/no-op without artifact or DB writes. -- When `model_id` is present, it passes directly into `DiffTraceInsert` as `Option<&str>` (`Some` for non-empty, `None` for absent/null). +- When `model_id` is present, it passes directly into `DiffTraceInsert` as `Option<&str>` (`Some` for non-empty, `None` for absent/null). The `payload_type` field is set to `PAYLOAD_TYPE_PATCH` for `OpenCode` normalized diff-trace payloads. - `time` is accepted as a `u64` Unix epoch millisecond input and must fit the signed `i64` `time_ms` column before any persistence starts. - The hook writes the existing collision-safe `context/tmp/-000000-diff-trace.json` parsed-payload artifact (when model enrichment succeeds or model_id was present), then attempts to insert the parsed payload fields through `AgentTraceDb::insert_diff_trace()`. - Command success requires artifact persistence to succeed; AgentTraceDb open/insert failures are logged and reflected in the success text as failed DB persistence instead of discarding the artifact fallback. @@ -128,9 +134,9 @@ The `sce hooks session-model` command route writes normalized session-model attr `AgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: -- SQL reads `id`, `time_ms`, `session_id`, `patch`, and nullable `model_id` + `tool_name` + `tool_version` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. +- SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. -- Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch`, then each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`); parsed row records also carry nullable `tool_name`/`tool_version` from the same source row and are returned as `ParsedDiffTracePatch` records. +- Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch`, then each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`); parsed row records also carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. - Malformed recent row patches are returned as `SkippedDiffTracePatch` records with deterministic parse-error reasons; malformed historical rows do not fail the operation. - `RecentDiffTracePatches::loaded_count()` and `skipped_count()` expose accounting for later hook output and persistence metadata. From 672e6e742d744dbd98898401fbab1963f586d996 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 15:47:20 +0200 Subject: [PATCH 13/18] agent-trace: Wire Claude structured payload classification into diff-trace hook Extend `sce hooks diff-trace` STDIN intake to classify and persist Claude structured `PostToolUse` payloads without converting them to unified-diff text at insert time. Co-authored-by: SCE --- cli/src/services/agent_trace_db/mod.rs | 1 - cli/src/services/hooks/mod.rs | 105 +++++++++++++++++- cli/src/services/structured_patch.rs | 2 - context/cli/structured-patch-service.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/plans/claude-rust-diff-trace.md | 3 +- .../sce/agent-trace-hooks-command-routing.md | 5 +- 8 files changed, 108 insertions(+), 14 deletions(-) diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 4411b1a3..e690f8fb 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -68,7 +68,6 @@ const AGENT_TRACE_MIGRATIONS: &[(&str, &str)] = &[ /// `OpenCode` normalized diff-trace payloads use [`PAYLOAD_TYPE_PATCH`]. /// `Claude` structured `PostToolUse` payloads use [`PAYLOAD_TYPE_STRUCTURED`]. pub const PAYLOAD_TYPE_PATCH: &str = "patch"; -#[allow(dead_code)] pub const PAYLOAD_TYPE_STRUCTURED: &str = "structured"; /// Parameterized SQL for inserting a captured diff trace payload. diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 70fb4674..1162b139 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -16,13 +16,16 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::{ AgentTraceDb, AgentTraceInsert, DiffTraceInsert, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, SessionModelUpsert, PAYLOAD_TYPE_PATCH, + RecentDiffTracePatches, SessionModelUpsert, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, }; use crate::services::observability::traits::Logger; use crate::services::patch::{ combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, parse_patch as parse_patch_from_text, ParsedPatch, }; +use crate::services::structured_patch::{ + derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult, +}; use crate::services::{config, default_paths::RepoPaths}; pub mod command; @@ -69,6 +72,14 @@ struct DiffTracePayload { model_id: Option, tool_name: String, tool_version: Option, + payload_type: String, +} + +/// Either a diff-trace payload to persist or a deterministic no-op result. +#[derive(Clone, Debug, Eq, PartialEq)] +enum DiffTraceParseResult { + Persist(DiffTracePayload), + NoOp(String), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -153,7 +164,11 @@ fn run_diff_trace_subcommand_from_payload( stdin_payload: &str, logger: Option<&dyn Logger>, ) -> Result { - let payload = parse_diff_trace_payload(stdin_payload)?; + let parse_result = parse_diff_trace_payload(stdin_payload)?; + let payload = match parse_result { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => return Ok(message), + }; let resolve_model = |tool_name: &str, session_id: &str| -> Result> { let db = AgentTraceDb::new().context("Failed to open Agent Trace DB for model resolution.")?; @@ -278,7 +293,7 @@ fn run_session_model_subcommand_from_payload( } } -fn parse_diff_trace_payload(stdin_payload: &str) -> Result { +fn parse_diff_trace_payload(stdin_payload: &str) -> Result { let payload_kind = StdinPayloadKind::DiffTrace; let parsed: Value = serde_json::from_str(stdin_payload) .with_context(|| payload_kind.validation_error("expected valid JSON"))?; @@ -286,6 +301,12 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result { .as_object() .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; + // Classify: Claude structured payloads carry hook_event_name. + if payload.contains_key("hook_event_name") { + return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); + } + + // OpenCode normalized payload — unchanged validation. let session_id = required_non_empty_string_field(payload, "sessionID", payload_kind)?; let diff = required_non_empty_string_field(payload, "diff", payload_kind)?; let time = required_u64_millisecond_field(payload, "time", payload_kind)?; @@ -294,14 +315,86 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result { let tool_version = required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; - Ok(DiffTracePayload { + Ok(DiffTraceParseResult::Persist(DiffTracePayload { session_id, diff, time, model_id, tool_name, tool_version, - }) + payload_type: PAYLOAD_TYPE_PATCH.to_string(), + })) +} + +/// Parse a Claude structured hook payload into a diff-trace intake result. +/// +/// Returns `NoOp` for events without diff traces and unsupported tool usage; +/// only supported `PostToolUse Write` / `Edit` events produce a `Persist` result. +fn parse_claude_diff_trace_payload( + payload: &serde_json::Map, + stdin_payload: &str, + payload_kind: StdinPayloadKind, +) -> Result { + let event_name = required_non_empty_string_field(payload, "hook_event_name", payload_kind)?; + + if event_name != "PostToolUse" { + return Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude '{event_name}' event has no diff trace; no-op." + ))); + } + + let time = extract_claude_event_time(payload); + + match derive_claude_structured_patch(&event_name, &Value::Object(payload.clone()), time, None) { + ClaudeStructuredPatchDerivationResult::Derived(patch) => { + Ok(DiffTraceParseResult::Persist(DiffTracePayload { + session_id: patch.session_id, + diff: stdin_payload.to_string(), + time: patch.time, + model_id: None, + tool_name: patch.tool_name, + tool_version: patch.tool_version, + payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), + })) + } + ClaudeStructuredPatchDerivationResult::Skipped(reason) => { + Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude PostToolUse event skipped ({reason:?}); no-op." + ))) + } + } +} + +/// Extract a u64 timestamp from a Claude hook event payload, falling back to the +/// current system time when no timestamp field is present. +fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { + for key in &["time", "timestamp"] { + if let Some(time_value) = payload.get(*key) { + if let Some(time) = time_value.as_u64() { + return time; + } + if let Some(time) = time_value.as_i64() { + if time >= 0 { + #[allow(clippy::cast_sign_loss)] + return time as u64; + } + } + if let Some(time) = time_value.as_f64() { + #[allow( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + clippy::cast_precision_loss + )] + if time >= 0.0 && time.fract() == 0.0 && time <= u64::MAX as f64 { + return time as u64; + } + } + } + } + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) } fn parse_session_model_payload(stdin_payload: &str) -> Result { @@ -511,7 +604,7 @@ where model_id, tool_name: &payload.tool_name, tool_version: payload.tool_version.as_deref(), - payload_type: PAYLOAD_TYPE_PATCH, + payload_type: &payload.payload_type, }) } diff --git a/cli/src/services/structured_patch.rs b/cli/src/services/structured_patch.rs index 68d8e734..8315ede4 100644 --- a/cli/src/services/structured_patch.rs +++ b/cli/src/services/structured_patch.rs @@ -6,8 +6,6 @@ //! is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured //! patches. -#![allow(dead_code)] - use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index bedf1c84..d6fe141b 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -22,7 +22,7 @@ ## Runtime wiring status -The module is not wired into `sce hooks diff-trace` yet. Current hook runtime still accepts normalized diff-trace JSON with raw diff text. The `diff_traces` table now supports a `payload_type` discriminator (`patch` for `OpenCode` unified-diff payloads, `structured` for `Claude` `PostToolUse` payloads) so structured payloads can be persisted and later parsed through `structured_patch.rs` during post-commit processing. Runtime intake wiring is planned in T04; post-commit parsing dispatch is planned in T05. +The module is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04): when `hook_event_name` is present and the event is a supported `PostToolUse` (`Write` create or `Edit` structured patch), the raw JSON is persisted as a `structured` payload type in `diff_traces` without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools) produce deterministic no-op results. OpenCode normalized payloads continue to be stored as `patch` payloads unchanged. Post-commit parsing dispatch through `structured_patch.rs` is planned in T05. ## Test status diff --git a/context/context-map.md b/context/context-map.md index 8997c1f6..b7189fdd 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -13,7 +13,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, set operations, and checked-in reconstruction/diff-creation fixture suites for `cli/src/services/patch.rs`; covers touched-line parsing for `Index:` SVN-style and `diff --git` git-style formats, `ParseError`/`PatchLoadError` diagnostics, `intersect_patches` target-shaped overlap with `model_id` provenance inheritance, `combine_patches` later-wins conflict resolution, active post-commit hook consumption, and the Claude derivation `diff_creation/` golden fixtures) -- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; not wired into hook runtime until the planned T04/T05 integration) +- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04), with post-commit parsing dispatch planned in T05) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) diff --git a/context/glossary.md b/context/glossary.md index 5573830f..66f02055 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -35,7 +35,7 @@ - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. - `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..009`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), `session_models` keyed by `(tool_name, session_id)`, and `diff_traces.payload_type` discriminator (`patch`/`structured` with default `'patch'`), with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata + `payload_type`, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). -- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; hook-runtime wiring is deferred to the planned T04/T05 integration. +- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04), with post-commit parsing dispatch deferred to T05. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index 80be85d6..c495df04 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -69,12 +69,13 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run focused AgentTraceDb tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test agent_trace_db'`; include migration/backward-compatibility test coverage where the current DB test harness supports it. - Completion evidence (2026-06-10): Added migration `009_add_diff_traces_payload_type.sql` adding `payload_type TEXT NOT NULL DEFAULT 'patch'` to `diff_traces`. Added `PAYLOAD_TYPE_PATCH` and `PAYLOAD_TYPE_STRUCTURED` constants to `agent_trace_db/mod.rs`. Updated `DiffTraceInsert`, `DiffTracePatchRow`, `ParsedDiffTracePatch` to carry `payload_type`. Updated `INSERT_DIFF_TRACE_SQL` and `SELECT_RECENT_DIFF_TRACE_PATCHES_SQL` to include `payload_type`. Updated `insert_diff_trace_with`, `diff_trace_patch_row_from_turso`, and `parse_recent_diff_trace_patch_rows` for the new column. Updated `hooks/mod.rs` to pass `PAYLOAD_TYPE_PATCH` for existing OpenCode diff-trace flow. Updated baseline migration test to expect 9 migrations. Added `payload_type` assertion to existing diff-trace query test. `nix flake check` passed. `nix run .#pkl-check-generated` passed. -- [ ] T04: `Persist Claude structured diff-trace source payloads` (status:todo) +- [x] T04: `Persist Claude structured diff-trace source payloads` (status:done) - Task ID: T04 - Goal: Extend `sce hooks diff-trace` STDIN intake so Claude structured `PostToolUse` payload JSON is classified and persisted as a structured source payload without converting it to a patchset at insert time. - Boundaries (in/out of scope): In - payload classification, validation errors/skips for unsupported/no-diff Claude events, insert adapter to the generic payload schema, tests for Claude structured payload intake and existing OpenCode normalized payload compatibility. Out - post-commit conversion to `ParsedPatch`, generated settings changes, OpenCode plugin changes. - Done when: `diff-trace` accepts existing OpenCode normalized payloads as `patch` payloads and Claude supported structured payloads as `structured` payloads; Claude unsupported/no-diff cases produce deterministic success/no-op or validation behavior consistent with current hook semantics; no Claude row is rendered to unified-diff text before DB persistence. - Verification notes (commands or checks): Run focused hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test hooks'`; include a targeted exact test when available. + - Completion evidence (2026-06-10): Extended `DiffTracePayload` with `payload_type` field. Added `DiffTraceParseResult` enum with `Persist` and `NoOp` variants. Modified `parse_diff_trace_payload` to classify payloads: if `hook_event_name` is present, the Claude path uses `derive_claude_structured_patch` to validate; unsupported events, non-PostToolUse events, and unsupported tools produce deterministic `NoOp` results. Claude `PostToolUse Write`/`Edit` payloads are classified as `structured` with the raw JSON stored as the `diff` column (not rendered to unified diff). OpenCode normalized payloads continue as `patch`. Updated `persist_diff_trace_payload_to_agent_trace_db_with` to use the payload's own `payload_type` instead of hardcoded `PAYLOAD_TYPE_PATCH`. Removed `#[allow(dead_code)]` from `PAYLOAD_TYPE_STRUCTURED` and `#![allow(dead_code)]` from `structured_patch.rs`. Generated tests were removed per review feedback. `nix flake check` passed (all check derivations green). `nix run .#pkl-check-generated` passed. - [ ] T05: `Parse typed diff-trace payloads during post-commit processing` (status:todo) - Task ID: T05 diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 5ff5827a..773446ad 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -56,7 +56,10 @@ - Post-commit Agent Trace success requires both schema validation and Agent Trace DB `agent_traces` persistence to succeed. - Current command-surface success output is: `post-commit hook processed intersection: commit=, intersection_files=`. - `post-rewrite` is a deterministic no-op entrypoint. -- `diff-trace` reads STDIN JSON, validates non-empty `sessionID`/`diff`/`tool_name`, optionally accepts `model_id` (absent or `null` → `None`; present non-empty → `Some`), validates required `tool_version` (must be present and either `null` or a non-empty string), validates required `u64` `time` (Unix epoch milliseconds), rejects `time` values that cannot fit the Agent Trace DB signed `time_ms` column. +- `diff-trace` reads STDIN JSON and classifies the payload: + - **Claude structured payloads** (detected by presence of top-level `hook_event_name`): the STDIN JSON is validated through `derive_claude_structured_patch`. Supported `PostToolUse` `Write` create and `Edit` structured-patch events produce a `DiffTracePayload` with `payload_type="structured"` and the raw event JSON stored as the `diff` column without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools, invalid payloads) produce a deterministic `NoOp` success result. + - **OpenCode normalized payloads** (no `hook_event_name`): existing flat `{ sessionID, diff, time, model_id?, tool_name, tool_version }` validation applies unchanged, with `payload_type="patch"`. + - The `DiffTracePayload` struct carries a `payload_type: String` field consumed by `persist_diff_trace_payload_to_agent_trace_db_with` to pass the correct discriminator to `DiffTraceInsert`. - When `model_id` is absent from the payload, Rust resolves it from the AgentTraceDb `session_models` table by `(tool_name, session_id)`. If a matching session model row is found, the payload is enriched with the resolved `model_id` before persistence. If no matching row is found, the hook returns success/no-op without writing artifact or DB rows (graceful skip). - When `model_id` is present in the payload, it is used directly without DB resolution. - Persistence: writes one parsed-payload artifact per invocation to `context/tmp/-000000-diff-trace.json` with atomic create-new retry semantics (only when model_id is resolved or present), and inserts the parsed payload fields into AgentTraceDb via `DiffTraceInsert` + `insert_diff_trace()` including nullable `model_id`. From 00d6f1c619b3662bb87170708159ccf19dc507ea Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 16:04:14 +0200 Subject: [PATCH 14/18] agent-trace-db: Parse typed diff-trace payloads during post-commit processing Update AgentTraceDb::recent_diff_trace_patches to dispatch on payload_type: patch rows continue through existing unified-diff parsing, while structured rows parse stored JSON and derive ParsedPatch via structured_patch::derive_claude_structured_patch at read time. Co-authored-by: SCE --- cli/src/services/agent_trace_db/mod.rs | 30 +++++++++++++-- cli/src/services/structured_patch.rs | 38 +++++++++++++++++++ context/architecture.md | 2 +- context/cli/structured-patch-service.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/plans/claude-rust-diff-trace.md | 3 +- context/sce/agent-trace-db.md | 5 ++- .../sce/agent-trace-hooks-command-routing.md | 3 +- 10 files changed, 79 insertions(+), 12 deletions(-) diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index e690f8fb..55ab628e 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -8,8 +8,11 @@ use crate::services::{ db::{DbSpec, TursoDb}, default_paths::agent_trace_db_path, patch::{parse_patch, ParseError, ParsedPatch}, + structured_patch::{derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult}, }; +use serde_json::Value; + pub mod lifecycle; const CREATE_DIFF_TRACES_MIGRATION: &str = @@ -427,7 +430,28 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif let mut skipped = Vec::new(); for row in rows { - match parse_patch(&row.patch) { + let parse_result = match row.payload_type.as_str() { + PAYLOAD_TYPE_PATCH => { + parse_patch(&row.patch).map_err(|error| skipped_diff_trace_patch_reason(&error)) + } + PAYLOAD_TYPE_STRUCTURED => match serde_json::from_str::(&row.patch) { + Ok(payload) => match derive_claude_structured_patch( + "PostToolUse", + &payload, + u64::try_from(row.time_ms).expect("diff trace time_ms should be non-negative"), + row.tool_version.as_deref(), + ) { + ClaudeStructuredPatchDerivationResult::Derived(derived) => Ok(derived.patch), + ClaudeStructuredPatchDerivationResult::Skipped(reason) => { + Err(reason.to_string()) + } + }, + Err(error) => Err(format!("invalid structured payload JSON: {error}")), + }, + other => Err(format!("unsupported diff-trace payload_type: {other}")), + }; + + match parse_result { Ok(mut patch) => { for file in &mut patch.files { for hunk in &mut file.hunks { @@ -445,11 +469,11 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif payload_type: row.payload_type, }); } - Err(error) => skipped.push(SkippedDiffTracePatch { + Err(reason) => skipped.push(SkippedDiffTracePatch { id: row.id, time_ms: row.time_ms, session_id: row.session_id, - reason: skipped_diff_trace_patch_reason(&error), + reason, }), } } diff --git a/cli/src/services/structured_patch.rs b/cli/src/services/structured_patch.rs index 8315ede4..2c683b02 100644 --- a/cli/src/services/structured_patch.rs +++ b/cli/src/services/structured_patch.rs @@ -6,6 +6,7 @@ //! is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured //! patches. +use std::fmt; use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; @@ -43,6 +44,43 @@ pub enum ClaudeStructuredPatchSkipReason { MissingSessionId, } +impl fmt::Display for ClaudeStructuredPatchSkipReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClaudeStructuredPatchSkipReason::UnsupportedEvent => { + write!(f, "unsupported Claude hook event") + } + ClaudeStructuredPatchSkipReason::EventWithoutDiffTrace => { + write!(f, "Claude event does not carry a diff trace") + } + ClaudeStructuredPatchSkipReason::InvalidPayload => { + write!(f, "invalid Claude structured payload") + } + ClaudeStructuredPatchSkipReason::EventNameMismatch => { + write!(f, "Claude payload event name mismatch") + } + ClaudeStructuredPatchSkipReason::UnsupportedTool => { + write!(f, "unsupported Claude tool") + } + ClaudeStructuredPatchSkipReason::UnsupportedWritePayload => { + write!(f, "unsupported Claude Write payload") + } + ClaudeStructuredPatchSkipReason::MissingFilePath => { + write!(f, "missing file path in Claude structured payload") + } + ClaudeStructuredPatchSkipReason::MissingFileContent => { + write!(f, "missing file content in Claude structured payload") + } + ClaudeStructuredPatchSkipReason::UnsupportedEditPayload => { + write!(f, "unsupported Claude Edit payload") + } + ClaudeStructuredPatchSkipReason::MissingSessionId => { + write!(f, "missing session_id in Claude structured payload") + } + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] enum PatchBuildResult { Built(ParsedPatch), diff --git a/context/architecture.md b/context/architecture.md index 7ce8f720..81a08a4a 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -121,7 +121,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - No user-invocable `sce sync` command is wired in the current runtime; local DB and Agent Trace DB bootstrap flows through lifecycle providers aggregated by setup, and DB health/repair flows through the doctor surface. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helpers (`load_patch_from_json` for string input, `load_patch_from_json_bytes` for byte input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. -- `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It currently derives Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free and is not yet wired into `sce hooks diff-trace`; that runtime integration is deferred to the active Claude Rust diff-trace migration plan. +- `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `AgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, version, completion, help, patch, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` trait, `RuntimeCommandHandle` type alias, `CommandRegistry` struct, and `build_default_registry()` function for the command dispatch registry. Service-owned command modules now own the migrated runtime command structs and `RuntimeCommand` impls for help/help-text, version, completion, auth, config, setup, doctor, and hooks. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and the shared `config/lib/` plugin package root, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. The shared config-lib source set is rooted at `config/lib/` and includes the shared `package.json`, `bun.lock`, and `tsconfig.json` plus `agent-trace-plugin/` and `bash-policy-plugin/`; `config-lib-bun-tests` runs Bun-discovered tests from that shared root, while `config-lib-biome-check` and `config-lib-biome-format` run Biome over the copied shared package source. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index d6fe141b..274753ee 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -22,7 +22,9 @@ ## Runtime wiring status -The module is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04): when `hook_event_name` is present and the event is a supported `PostToolUse` (`Write` create or `Edit` structured patch), the raw JSON is persisted as a `structured` payload type in `diff_traces` without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools) produce deterministic no-op results. OpenCode normalized payloads continue to be stored as `patch` payloads unchanged. Post-commit parsing dispatch through `structured_patch.rs` is planned in T05. +The module is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04): when `hook_event_name` is present and the event is a supported `PostToolUse` (`Write` create or `Edit` structured patch), the raw JSON is persisted as a `structured` payload type in `diff_traces` without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools) produce deterministic no-op results. OpenCode normalized payloads continue to be stored as `patch` payloads unchanged. + +Post-commit parsing dispatch through `structured_patch.rs` is implemented (T05): `AgentTraceDb::recent_diff_trace_patches` now reads `payload_type` from each `diff_traces` row and dispatches `patch` rows through existing `parse_patch` while dispatching `structured` rows through `derive_claude_structured_patch` at read time, producing `ParsedPatch` for both paths before hunk `model_id` injection and downstream combine/intersect operations. ## Test status diff --git a/context/context-map.md b/context/context-map.md index b7189fdd..50b3d848 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -13,7 +13,7 @@ Feature/domain context: - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/Agent Trace databases, repo-relative, embedded-asset, install/runtime, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, set operations, and checked-in reconstruction/diff-creation fixture suites for `cli/src/services/patch.rs`; covers touched-line parsing for `Index:` SVN-style and `diff --git` git-style formats, `ParseError`/`PatchLoadError` diagnostics, `intersect_patches` target-shaped overlap with `model_id` provenance inheritance, `combine_patches` later-wins conflict resolution, active post-commit hook consumption, and the Claude derivation `diff_creation/` golden fixtures) -- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04), with post-commit parsing dispatch planned in T05) +- `context/cli/structured-patch-service.md` (synchronous Rust `cli/src/services/structured_patch.rs` service for deriving Claude `PostToolUse` `Write` create and `Edit` structured-patch payloads into canonical `ParsedPatch` values with deterministic skip reasons; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04), and wired into `AgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05)) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces, and per-column right-to-left RGB gradient banner rendering) - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract, config-file selection order, `show` provenance output, trimmed `validate` output contract, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/cli/capability-traits.md` (current broad CLI dependency-injection capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, AppContext wiring with capability accessors plus repo-root-scoped context derivation, and test-only unimplemented stubs; current service internals do not consume these traits until later lifecycle migration tasks) diff --git a/context/glossary.md b/context/glossary.md index 66f02055..e54584db 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -35,7 +35,7 @@ - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. - `agent trace DB adapter`: Module in `cli/src/services/agent_trace_db/mod.rs` that defines `AgentTraceDbSpec`, exposes `AgentTraceDb` as a `TursoDb` alias, resolves `/sce/agent-trace.db` through `agent_trace_db_path()`, embeds an ordered split fresh-start migration set (`001..009`) that creates `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), `session_models` keyed by `(tool_name, session_id)`, and `diff_traces.payload_type` discriminator (`patch`/`structured` with default `'patch'`), with `agent_traces.agent_trace_id` enforced as `NOT NULL UNIQUE`; provides typed parameterized insert/upsert helpers for diff traces including `model_id` + tool metadata + `payload_type`, post-commit intersection rows, built agent-trace rows (including `agent_trace_id`), and session model attribution rows; exposes chronological recent `diff_traces` query/parse support with malformed-row skip accounting; has `AgentTraceDbLifecycle` for setup/doctor integration; and is written by `sce hooks diff-trace` (`diff_traces`) plus `sce hooks post-commit` (`post_commit_patch_intersections` and built `agent_traces`). -- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04), with post-commit parsing dispatch deferred to T05. +- `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `AgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. diff --git a/context/overview.md b/context/overview.md index 8385fa87..dc6871ff 100644 --- a/context/overview.md +++ b/context/overview.md @@ -46,7 +46,7 @@ Context sync now uses an important-change gate: cross-cutting/policy/architectur The `/change-to-plan` command body is also intentionally thin orchestration: it delegates clarification and plan-shape contracts to `sce-plan-authoring` (including one-task/one-atomic-commit task slicing) while keeping wrapper-level plan output and handoff obligations explicit. The generated OpenCode command doc now also emits `entry-skill: sce-plan-authoring` plus an ordered `skills` list. The targeted support commands (`handover`, `commit`, `validate`) keep their thin-wrapper behavior and now also emit machine-readable OpenCode command frontmatter describing their entry skill and ordered skill chain. `/commit` is now split by profile: manual generated commands remain proposal-only and allow split guidance when staged changes mix unrelated goals, while the automated OpenCode `/commit` command generates exactly one commit message and runs `git commit` against the staged diff. The shared `sce-atomic-commit` contract also requires commit bodies to cite affected plan slug(s) and updated task ID(s) when staged changes include `context/plans/*.md`, and to stop for clarification instead of inventing those references when the staged plan diff is ambiguous. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. -The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days, combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error — resolved from `session_models` by `tool_name` + `session_id` when absent), required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence, with Claude `model_id` resolved from `session_models` at Rust persistence time. +The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled and `SCE_DISABLED` is false; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error — resolved from `session_models` by `tool_name` + `session_id` when absent), required `tool_version` (must be present and either `null` or a non-empty string), plus required `u64` millisecond `time`, with non-lossy AgentTraceDb `time_ms` conversion and collision-safe timestamp+attempt artifact filenames; and `session-model` performs STDIN intake for normalized model attribution upsert without raw artifact persistence, with Claude `model_id` resolved from `session_models` at Rust persistence time. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime now matches the implemented T06 slice for `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, and bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support dual-mode operation — local mode via `turso::Builder::new_local()` when `SCE_SYNC_URL`+`SCE_SYNC_TOKEN` are absent, or sync (Turso Cloud) mode via `turso::sync::Builder::new_remote()` when both are set. It owns parent-directory creation, connection setup, tokio current-thread runtime bridging, synchronous `execute`/`query`/`query_map`, generic migration execution, sync operations (`push`/`pull`/`checkpoint`/`stats`) that are no-ops in local mode (sync is never triggered automatically from `execute()`), and shared DB lifecycle helpers for service-specific database wrappers. Auth DB persistence now has a thin encrypted wrapper in `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb` resolves `/sce/auth.db` and embeds ordered `auth_tokens` table/index migrations, with lifecycle registration wired through `AuthDbLifecycle` in `cli/src/services/auth_db/lifecycle.rs`; auth runtime token-storage is now wired through `token_storage.rs`, which persists tokens via the `auth_credentials` table instead of a JSON file. Agent Trace persistence now has its own `cli/src/services/agent_trace_db/mod.rs` wrapper, canonical `/sce/agent-trace.db` path, a split fresh-start baseline migration set (`001..008`) covering `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, nullable `agent_traces.remote_url`, indexes (`idx_diff_traces_time_ms_id`, `idx_agent_traces_agent_trace_id`, `idx_agent_traces_remote_url`), and `session_models` keyed by `(tool_name, session_id)` without `AUTOINCREMENT`, plus `agent_traces.agent_trace_id` as `NOT NULL UNIQUE`; it also provides typed parameterized insert helpers for diff traces, post-commit intersection rows, and built agent-trace rows, chronological recent `diff_traces` query/parse support with malformed-row skip accounting, registered setup/doctor lifecycle provider, active `sce hooks diff-trace` writes for `diff_traces`, and active `sce hooks post-commit` writes for built `agent_traces` payloads. The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `session-model`) with deterministic argument/STDIN validation. Current runtime behavior keeps attribution disabled by default: the attribution gate enables canonical trailer insertion in `commit-msg`, `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, and remains the active bounded recent-diff-trace intersection path, `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id` (absent/`null` → `None`, resolved from `session_models` by `tool_name` + `session_id`), required non-empty `tool_name`, required nullable/non-empty `tool_version`, required `u64` millisecond `time`, non-lossy AgentTraceDb `time_ms` conversion, and collision-safe timestamp+attempt artifact filenames; and `session-model` is the active STDIN intake for normalized model attribution upsert. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index c495df04..068cce16 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -77,12 +77,13 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run focused hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test hooks'`; include a targeted exact test when available. - Completion evidence (2026-06-10): Extended `DiffTracePayload` with `payload_type` field. Added `DiffTraceParseResult` enum with `Persist` and `NoOp` variants. Modified `parse_diff_trace_payload` to classify payloads: if `hook_event_name` is present, the Claude path uses `derive_claude_structured_patch` to validate; unsupported events, non-PostToolUse events, and unsupported tools produce deterministic `NoOp` results. Claude `PostToolUse Write`/`Edit` payloads are classified as `structured` with the raw JSON stored as the `diff` column (not rendered to unified diff). OpenCode normalized payloads continue as `patch`. Updated `persist_diff_trace_payload_to_agent_trace_db_with` to use the payload's own `payload_type` instead of hardcoded `PAYLOAD_TYPE_PATCH`. Removed `#[allow(dead_code)]` from `PAYLOAD_TYPE_STRUCTURED` and `#![allow(dead_code)]` from `structured_patch.rs`. Generated tests were removed per review feedback. `nix flake check` passed (all check derivations green). `nix run .#pkl-check-generated` passed. -- [ ] T05: `Parse typed diff-trace payloads during post-commit processing` (status:todo) +- [x] T05: `Parse typed diff-trace payloads during post-commit processing` (status:done) - Task ID: T05 - Goal: Update post-commit recent-diff-trace processing so typed persisted payloads are converted into `ParsedPatch` at read/processing time. - Boundaries (in/out of scope): In - parser dispatch for `payload_type="patch"` through existing patch parsing, parser dispatch for `payload_type="structured"` through `structured_patch.rs`, malformed-row skip accounting, model/tool metadata preservation, tests covering mixed OpenCode+Claude rows. Out - DB schema changes beyond T03, hook settings generation, Agent Trace output schema changes. - Done when: Post-commit combines/intersects OpenCode patch rows and Claude structured rows through the same `ParsedPatch` pipeline; structured Claude rows derive the same patch output as Rust golden fixtures; malformed or unsupported stored payloads are skipped/reportable without breaking valid rows. - Verification notes (commands or checks): Run focused post-commit/hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test post_commit'` or the narrowest matching selector once test names are known. + - Completion evidence (2026-06-10): Modified `parse_recent_diff_trace_patch_rows` in `agent_trace_db/mod.rs` to dispatch on `payload_type`: `patch` rows use existing `parse_patch`, `structured` rows parse stored JSON and derive `ParsedPatch` via `derive_claude_structured_patch` at read time, other payload types are skipped deterministically. Added `Display` impl for `ClaudeStructuredPatchSkipReason` in `structured_patch.rs`. `nix flake check` passed (all 4 checks green). `nix run .#pkl-check-generated` passed. - [ ] T06: `Render Claude settings with direct sce hook commands` (status:todo) - Task ID: T06 diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 412c2340..1b1a08a2 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -136,8 +136,9 @@ The `sce hooks session-model` command route writes normalized session-model attr - SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. -- Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch`, then each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`); parsed row records also carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. -- Malformed recent row patches are returned as `SkippedDiffTracePatch` records with deterministic parse-error reasons; malformed historical rows do not fail the operation. +- Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch` for `payload_type="patch"` rows (OpenCode unified-diff payloads), while `payload_type="structured"` rows (Claude `PostToolUse` structured payloads) are parsed from stored JSON through `cli/src/services/structured_patch.rs` `derive_claude_structured_patch` at read time to produce `ParsedPatch` without pre-rendered unified-diff text. +- Each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`) for both patch and structured paths; parsed row records also carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. +- Malformed recent row patches (invalid unified-diff text, invalid structured JSON, unsupported payload types, or unsupported Claude structured payloads) are returned as `SkippedDiffTracePatch` records with deterministic parse-error or derivation-skip reasons; malformed historical rows do not fail the operation. - `RecentDiffTracePatches::loaded_count()` and `skipped_count()` expose accounting for later hook output and persistence metadata. See also: [shared-turso-db.md](shared-turso-db.md), [local-db.md](local-db.md), [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md), [context-map.md](../context-map.md) diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 773446ad..7be06abf 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -38,7 +38,8 @@ - **`post-commit` is an active intersection entrypoint** (see [agent-trace-db.md](agent-trace-db.md)): - Captures the current commit's patch from git using `capture_post_commit_patch_from_git()`. - Queries recent `diff_traces` patches from the past 7 days via `AgentTraceDb::recent_diff_trace_patches()`. - - Recent-row patch parsing carries nullable row `model_id` into each produced `PatchHunk`, so combined/intersection patch inputs retain per-hunk model provenance for downstream Agent Trace attribution building. + - Recent-row parsing dispatches on `payload_type`: `patch` rows parse through existing `parse_patch`, while `structured` rows parse stored JSON through `structured_patch::derive_claude_structured_patch` at read time to produce `ParsedPatch`. + - Parsed `PatchHunk` entries carry nullable row `model_id` for both paths, so combined/intersection patch inputs retain per-hunk model provenance for downstream Agent Trace attribution building. - Combines valid recent patches in chronological order via `patch::combine_patches`. - Intersects the combined recent patch with the post-commit patch via `patch::intersect_patches`. - Persists the serialized intersection result to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms, end_ms), and loaded/skipped counts. From cc2144b665b3669855092b43acab691c57accc82 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 16:21:10 +0200 Subject: [PATCH 15/18] hooks: Route Claude agent-trace hooks through native CLI intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Bun/TypeScript Claude hook invocations with direct `sce hooks` commands. Claude settings now pipe raw hook event JSON on STDIN: `SessionStart` → `sce hooks session-model`, matched `PostToolUse` → `sce hooks diff-trace`. The Rust `session-model` intake detects Claude payloads via `hook_event_name` and extracts session_id / model_id / time / tool_version from the raw Claude event format, normalizing model_id with a `claude/` prefix. The diff-trace intake already handled Claude structured-patch derivation. Co-authored-by: SCE --- cli/src/services/hooks/mod.rs | 120 ++++++++++++++++++ config/.claude/plugins/sce-agent-trace.ts | 1 + config/.claude/settings.json | 40 +----- config/pkl/renderers/claude-content.pkl | 40 +----- context/context-map.md | 6 +- context/plans/claude-rust-diff-trace.md | 3 +- .../sce/agent-trace-hooks-command-routing.md | 7 +- .../generated-opencode-plugin-registration.md | 6 +- .../opencode-agent-trace-plugin-runtime.md | 10 +- 9 files changed, 152 insertions(+), 81 deletions(-) diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 1162b139..efcc9ed1 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -405,6 +405,12 @@ fn parse_session_model_payload(stdin_payload: &str) -> Result Result, + payload_kind: StdinPayloadKind, +) -> Result { + let event_name = required_non_empty_string_field(payload, "hook_event_name", payload_kind)?; + + if event_name != "SessionStart" { + bail!(payload_kind.validation_error(&format!( + "Claude '{event_name}' event is not supported for session-model intake (expected SessionStart)" + ))); + } + + let session_id = required_claude_session_id(payload, payload_kind)?; + let model_id = required_claude_model_id(payload, payload_kind)?; + let time = extract_claude_event_time(payload); + let tool_name = "claude".to_string(); + let tool_version = extract_claude_tool_version_from_payload(payload); + + Ok(SessionModelPayload { + session_id, + time, + model_id, + tool_name, + tool_version, + }) +} + +fn required_claude_session_id( + payload: &serde_json::Map, + payload_kind: StdinPayloadKind, +) -> Result { + for key in ["session_id", "sessionID"] { + if let Some(value) = payload.get(key) { + if let Some(s) = value.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + } + } + bail!(payload_kind.validation_error( + "missing non-empty 'session_id' or 'sessionID' field for Claude SessionStart" + )) +} + +fn required_claude_model_id( + payload: &serde_json::Map, + payload_kind: StdinPayloadKind, +) -> Result { + // Try direct string fields first. + for key in ["model", "model_id", "modelId"] { + if let Some(value) = payload.get(key) { + if let Some(s) = value.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Ok(normalize_claude_model_id(trimmed)); + } + } + // If model is an object, try nested identifier fields. + if let Some(model_obj) = value.as_object() { + for nested_key in ["id", "model", "name"] { + if let Some(nested_value) = model_obj.get(nested_key) { + if let Some(s) = nested_value.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Ok(normalize_claude_model_id(trimmed)); + } + } + } + } + } + } + } + + bail!(payload_kind.validation_error( + "missing non-empty model identifier (model, model_id, or model.id) for Claude SessionStart" + )) +} + +fn normalize_claude_model_id(model: &str) -> String { + if model.starts_with(CLAUDE_MODEL_ID_PREFIX) { + model.to_string() + } else { + format!("{CLAUDE_MODEL_ID_PREFIX}{model}") + } +} + +fn extract_claude_tool_version_from_payload( + payload: &serde_json::Map, +) -> Option { + for key in ["tool_version", "claude_version", "version"] { + match payload.get(key) { + Some(Value::String(s)) => { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + return None; // empty string → None (null semantics) + } + Some(Value::Null) => return None, + Some(_) | None => {} // non-string, non-null, or missing → skip + } + } + None +} + fn required_nullable_or_non_empty_string_field( payload: &serde_json::Map, field_name: &str, diff --git a/config/.claude/plugins/sce-agent-trace.ts b/config/.claude/plugins/sce-agent-trace.ts index bb0c52e7..b7a3ac75 100644 --- a/config/.claude/plugins/sce-agent-trace.ts +++ b/config/.claude/plugins/sce-agent-trace.ts @@ -711,6 +711,7 @@ export async function runClaudeHookRuntime( return; } + // For PostToolUse, attempt best-effort diff-trace forwarding if (eventName !== "PostToolUse") { return; } diff --git a/config/.claude/settings.json b/config/.claude/settings.json index 98622696..287f67e0 100644 --- a/config/.claude/settings.json +++ b/config/.claude/settings.json @@ -5,24 +5,10 @@ "hooks": [ { "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "SessionStart" - ] - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "UserPromptSubmit" + "hooks", + "session-model" ] } ] @@ -34,24 +20,10 @@ "hooks": [ { "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "PostToolUse" - ] - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "Stop" + "hooks", + "diff-trace" ] } ] diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index 5e14bdc1..9ea942e4 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -60,24 +60,10 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "SessionStart" - ] - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "UserPromptSubmit" + "hooks", + "session-model" ] } ] @@ -89,24 +75,10 @@ settings = new common.RenderedTextFile { "hooks": [ { "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "PostToolUse" - ] - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "Stop" + "hooks", + "diff-trace" ] } ] diff --git a/context/context-map.md b/context/context-map.md index 50b3d848..484590fb 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -50,12 +50,12 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, and always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation as nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` resolved from `session_models` when absent, required nullable/non-empty `tool_version`, required `u64` `time` validation, collision-safe `context/tmp/-000000-diff-trace.json` artifacts, best-effort AgentTraceDb insertion, and `session-model` STDIN intake for normalized model attribution upsert without raw artifact persistence; the removed `claude-capture` route was deleted in T05) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: disabled-default commit-msg attribution, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection entrypoint requiring validated `--remote-url`, threading that URL to the Agent Trace flow, printing it to stderr, capturing current commit patch, querying recent `diff_traces` from past 7 days, combining/intersecting patches via `patch::combine_patches` / `patch::intersect_patches`, persisting results to `post_commit_patch_intersections`, building/schema-validating post-commit Agent Trace payloads enriched with optional top-level `tool` metadata, `metadata.sce.version`, and range `content_hash`, and persisting validated payloads to AgentTraceDb `agent_traces` (DB-only), plus `diff-trace` STDIN intake with Claude structured payload detection via `hook_event_name` and OpenCode normalized payload support, `session-model` STDIN intake with Claude `SessionStart` payload detection via `hook_event_name` (extracts `session_id`/`model_id`/`time`/`tool_version` from raw Claude event format), normalized OpenCode session-model intake, and AgentTraceDb `session_models` upsert without raw artifact persistence; the removed `claude-capture` route was deleted in T05) - `context/sce/claude-raw-hook-capture.md` (removed feature — `claude-capture` CLI route, `ClaudeCaptureEvent`, `claude_transcript.rs`, and `RepoPaths::claude_capture_tmp_dir()` were deleted in T05. Rust now exposes only normalized `session-model` and `diff-trace` intakes.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus the implementation target for generated OpenCode enforcement, including config schema, argv-prefix matching, fixed preset catalog/messages, and precedence rules) -- `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and the boundary that Claude uses generated `.claude/settings.json` + Bun runtime registration instead of OpenCode plugin manifests; Claude bash-policy enforcement has been removed from generated outputs) -- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode and Claude TypeScript agent-trace runtime behavior, including OpenCode `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name` plus required nullable `tool_version`, the shared OpenCode/Claude TypeScript-runtime-to-Rust `diff-trace` boundary, Claude `PostToolUse` Write/Edit derivation through `deriveClaudeDiffTracePayload(...)` (TypeScript) and `derive_claude_structured_patch(...)` (Rust), and Rust-owned derivation golden tests over `cli/src/services/structured_patch/fixtures/`; Rust hook parsing and AgentTraceDb insertion persist parsed payload fields including optional/resolved `model_id`; `session.diff` event capture has been removed) +- `context/sce/generated-opencode-plugin-registration.md` (current generated OpenCode plugin-registration contract, canonical Pkl ownership, generated manifest/plugin paths including `sce-bash-policy` + `sce-agent-trace`, TypeScript source ownership, and the boundary that Claude uses generated `.claude/settings.json` command hooks that call `sce hooks` directly without a TypeScript plugin intermediary; Claude bash-policy enforcement has been removed from generated outputs) +- `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode TypeScript agent-trace runtime behavior and the Claude `sce hooks` direct command-hook boundary, including OpenCode `message.updated` capture filtered to user messages with diffs, `{ sessionID, diff, time, model_id }` extraction from message info, session-scoped OpenCode client version capture from `session.created`/`session.updated`, CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name` plus required nullable `tool_version`, Claude `SessionStart` → `sce hooks session-model` and matched `PostToolUse Write|Edit` → `sce hooks diff-trace` via generated settings command hooks with raw hook event JSON on STDIN, Rust Claude derivation through `derive_claude_structured_patch(...)`, and Rust-owned derivation golden tests over `cli/src/services/structured_patch/fixtures/`; Rust hook parsing and AgentTraceDb insertion persist parsed payload fields including optional/resolved `model_id`; `session.diff` event capture has been removed) - `context/sce/cli-first-install-channels-contract.md` (current first-wave `sce` install/distribution contract covering supported channels, canonical naming, `.version` release authority, and Nix-owned build policy) - `context/sce/optional-install-channel-integration-test-entrypoint.md` (current opt-in flake app contract for install-channel integration coverage, including thin flake delegation to the Rust runner, shared harness ownership, real npm+Bun+Cargo install flows, channel selector semantics, and the explicit non-default execution boundary) - `context/sce/cli-release-artifact-contract.md` (shared `sce` release artifact naming, checksum/manifest outputs, GitHub Releases as the canonical artifact publication surface, and the current three-target Linux/macOS release workflow topology) diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index 068cce16..b386929f 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -85,12 +85,13 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run focused post-commit/hooks tests through Nix, for example `nix develop -c sh -c 'cd cli && cargo test post_commit'` or the narrowest matching selector once test names are known. - Completion evidence (2026-06-10): Modified `parse_recent_diff_trace_patch_rows` in `agent_trace_db/mod.rs` to dispatch on `payload_type`: `patch` rows use existing `parse_patch`, `structured` rows parse stored JSON and derive `ParsedPatch` via `derive_claude_structured_patch` at read time, other payload types are skipped deterministically. Added `Display` impl for `ClaudeStructuredPatchSkipReason` in `structured_patch.rs`. `nix flake check` passed (all 4 checks green). `nix run .#pkl-check-generated` passed. -- [ ] T06: `Render Claude settings with direct sce hook commands` (status:todo) +- [x] T06: `Render Claude settings with direct sce hook commands` (status:done) - Task ID: T06 - Goal: Update canonical Pkl-generated Claude settings so Claude invokes `sce hooks session-model` and `sce hooks diff-trace` directly instead of running Bun against `.claude/plugins/sce-agent-trace.ts`. - Boundaries (in/out of scope): In - `config/pkl/renderers/claude-content.pkl` settings command definitions and regenerated `config/.claude/settings.json` / repo-root `.claude/settings.json` outputs. Out - OpenCode renderer/plugin registration, agent/skill content changes unrelated to settings, manual edits to generated outputs without source updates. - Done when: Generated Claude settings contain no `.claude/plugins/sce-agent-trace.ts` or `bun` hook invocation for agent tracing, and route `SessionStart` to `sce hooks session-model` while routing matched `PostToolUse` to `sce hooks diff-trace` with Claude hook payload on STDIN according to Claude hook command behavior. - Verification notes (commands or checks): Run `nix develop -c pkl eval -m . config/pkl/generate.pkl` after source edits, then `nix run .#pkl-check-generated`. + - Completion evidence (2026-06-10): Replaced `claude-content.pkl` settings block: `SessionStart` routes to `sce hooks session-model`, `PostToolUse Write|Edit|MultiEdit|NotebookEdit` routes to `sce hooks diff-trace`, removed `UserPromptSubmit`/`Stop` hooks. Added `parse_claude_session_model_payload` to `cli/src/services/hooks/mod.rs` so the Rust `session-model` intake handles raw Claude `SessionStart` payloads (extracts `session_id`/`model_id`/`time`/`tool_version`, normalizes `model_id` with `claude/` prefix). Regenerated `config/.claude/settings.json`. `nix flake check` passed (all 4 checks green). `nix run .#pkl-check-generated` passed. - [ ] T07: `Remove Claude TypeScript plugin source and generated outputs` (status:todo) - Task ID: T07 diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 7be06abf..bb90d126 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -64,12 +64,15 @@ - When `model_id` is absent from the payload, Rust resolves it from the AgentTraceDb `session_models` table by `(tool_name, session_id)`. If a matching session model row is found, the payload is enriched with the resolved `model_id` before persistence. If no matching row is found, the hook returns success/no-op without writing artifact or DB rows (graceful skip). - When `model_id` is present in the payload, it is used directly without DB resolution. - Persistence: writes one parsed-payload artifact per invocation to `context/tmp/-000000-diff-trace.json` with atomic create-new retry semantics (only when model_id is resolved or present), and inserts the parsed payload fields into AgentTraceDb via `DiffTraceInsert` + `insert_diff_trace()` including nullable `model_id`. - - Current TypeScript producers are the OpenCode agent-trace plugin and the generated Claude Bun runtime. + - Current TypeScript producers are the OpenCode agent-trace plugin and the generated Claude `sce hooks` command hooks (no TypeScript intermediary). - OpenCode forwards user-message `message.updated` diffs with `tool_name="opencode"`, always including `model_id`, and nullable OpenCode client-version metadata. - Claude forwards supported `PostToolUse` `Write` create and `Edit` structured-patch diffs with `tool_name="claude"`, omitting `model_id` from the payload and relying on Rust DB resolution. - Neither TypeScript runtime writes `context/tmp/*-diff-trace.json` artifacts or AgentTraceDb rows directly. - `diff-trace` command success requires artifact persistence to succeed. AgentTraceDb open/insert failures are logged through `sce.hooks.diff_trace.agent_trace_db_write_failed` and reflected in the success text as failed DB persistence, while the parsed-payload artifact remains the durable fallback. -- `session-model` reads STDIN JSON, validates required non-empty `sessionID`/`model_id`/`tool_name`, required `u64` `time` (Unix epoch milliseconds, maps to `session_start_time_ms`), and required nullable/non-empty `tool_version`. Valid payloads are upserted into AgentTraceDb `session_models` via `SessionModelUpsert` using `(tool_name, session_id)` as the unique key. No raw hook artifacts are written. DB open/insert failures are logged through `sce.hooks.session_model.agent_trace_db_write_failed` and reported in the success text as failed persistence. +- `session-model` reads STDIN JSON and classifies the payload: + - **Claude `SessionStart` payloads** (detected by presence of top-level `hook_event_name`): extracts `session_id` from `session_id`/`sessionID`, `model_id` from `model`/`model_id` (including nested `model.id`/`model.model`/`model.name` with `claude/` prefix normalization), `time` from `time`/`timestamp` (falls back to current system time), `tool_name="claude"`, and `tool_version` from `tool_version`/`claude_version`/`version`. + - **OpenCode normalized payloads** (no `hook_event_name`): existing `{ sessionID, time, model_id, tool_name, tool_version }` validation applies unchanged. + - Valid payloads are upserted into AgentTraceDb `session_models` via `SessionModelUpsert` using `(tool_name, session_id)` as the unique key. No raw hook artifacts are written. DB open/insert failures are logged through `sce.hooks.session_model.agent_trace_db_write_failed` and reported in the success text as failed persistence. ## Explicit non-goals in the current baseline - No checkpoint handoff file diff --git a/context/sce/generated-opencode-plugin-registration.md b/context/sce/generated-opencode-plugin-registration.md index 13d0f94a..a67223e6 100644 --- a/context/sce/generated-opencode-plugin-registration.md +++ b/context/sce/generated-opencode-plugin-registration.md @@ -23,9 +23,9 @@ The generated-config pipeline now has one canonical Pkl-authored source for Open ## Claude boundary -- Claude does not consume this OpenCode `plugin` manifest surface. -- Claude agent-trace event handling is registered through generated `.claude/settings.json` command hooks that run `bun .claude/plugins/sce-agent-trace.ts `. -- The generated Claude TypeScript runtime is an event adapter only: it sends normalized `session-model` payloads to Rust for `SessionStart` model attribution, forwards supported normalized `diff-trace` payloads to `sce hooks diff-trace`, and Rust remains the artifact and database writer. +- Claude does not consume the OpenCode `plugin` manifest surface. +- Claude agent-trace event handling is registered through generated `.claude/settings.json` command hooks that call `sce hooks` directly: `SessionStart` → `sce hooks session-model`, matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` → `sce hooks diff-trace`. +- The Rust CLI receives raw Claude hook event JSON on STDIN and handles extraction, validation, and persistence without a TypeScript translation layer. - Claude bash-policy enforcement has been removed from generated outputs. - OpenCode is now the sole target for SCE-managed bash-policy enforcement via the plugin registration contract. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index 3e855da3..840ced18 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -40,10 +40,12 @@ Otherwise, the helper returns `undefined`. ## Shared boundary with Claude runtime -- OpenCode and Claude now both use generated TypeScript event runtimes as event-shape adapters before handing normalized diff-trace payloads to the shared Rust hook intake. -- OpenCode registration remains the generated OpenCode `opencode.json` plugin manifest; Claude registration remains generated `.claude/settings.json` command hooks that run Bun against `.claude/plugins/sce-agent-trace.ts`. -- The shared Rust boundary is `sce hooks diff-trace`: both runtimes send `{ sessionID, diff, time, model_id, tool_name, tool_version }` over STDIN JSON, and Rust remains the only writer of parsed `context/tmp/*-diff-trace.json` artifacts and AgentTraceDb `diff_traces` rows. -- Claude `model_id` differs from OpenCode attribution: OpenCode reads provider/model data from the OpenCode event, while Claude resolves `model_id` from AgentTraceDb `session_models` at Rust persistence time and skips `diff-trace` persistence when no matching session model row exists. +- OpenCode uses a generated TypeScript event runtime as an event-shape adapter before handing normalized diff-trace payloads to the shared Rust hook intake. +- Claude registration uses generated `.claude/settings.json` command hooks that call `sce hooks` directly (no TypeScript runtime intermediary): `SessionStart` pipes the raw Claude hook event JSON to `sce hooks session-model`, and matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes the raw hook event to `sce hooks diff-trace`. +- Rust `diff-trace` intake detects Claude payloads via `hook_event_name` and derives structured patches from the raw JSON with `payload_type="structured"`; OpenCode normalized payloads (no `hook_event_name`) are stored as `payload_type="patch"`. +- Rust `session-model` intake detects Claude `SessionStart` payloads via `hook_event_name` and extracts `session_id`/`model_id`/`time`/`tool_version` directly from the raw Claude event format. +- The shared Rust boundary is `sce hooks diff-trace` and `sce hooks session-model`: Rust remains the only writer of parsed `context/tmp/*-diff-trace.json` artifacts and AgentTraceDb `diff_traces`/`session_models` rows. +- Claude `model_id` differs from OpenCode attribution: OpenCode reads provider/model data from the OpenCode event and includes `model_id` in the payload; for Claude `diff-trace`, Rust resolves `model_id` from AgentTraceDb `session_models` at persistence time and skips persistence when no matching session model row exists; for Claude `session-model`, Rust extracts `model_id` from the raw hook event and normalizes it with a `claude/` prefix. ## Claude derivation golden tests From e0916975a7aa9e4141ba4d23b6dbe82d4d9666e6 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 16:32:49 +0200 Subject: [PATCH 16/18] claude: Remove TypeScript agent-trace plugin and generated outputs Claude diff-trace and session-model hooks are now handled by the Rust CLI via `sce hooks` subcommands (completed in T06). This removes the obsolete TypeScript Bun plugin runtime. Co-authored-by: SCE --- .claude/plugins/sce-agent-trace.ts | 836 ------------------ .claude/settings.json | 42 +- config/.claude/plugins/sce-agent-trace.ts | 836 ------------------ .../claude-sce-agent-trace-plugin.test.ts | 103 --- .../claude-sce-agent-trace-plugin.ts | 836 ------------------ config/pkl/generate.pkl | 5 - context/plans/claude-rust-diff-trace.md | 13 +- 7 files changed, 14 insertions(+), 2657 deletions(-) delete mode 100644 .claude/plugins/sce-agent-trace.ts delete mode 100644 config/.claude/plugins/sce-agent-trace.ts delete mode 100644 config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts delete mode 100644 config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts diff --git a/.claude/plugins/sce-agent-trace.ts b/.claude/plugins/sce-agent-trace.ts deleted file mode 100644 index b7a3ac75..00000000 --- a/.claude/plugins/sce-agent-trace.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; - -export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ - "SessionStart", - "UserPromptSubmit", - "PostToolUse", - "Stop", -] as const; - -export type ClaudeAgentTraceEventName = - (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; - -export type ClaudeDiffTracePayload = { - sessionID: string; - diff: string; - time: number; - model_id?: string; - tool_name: "claude"; - tool_version: string | null; -}; - -export type ClaudeDiffTraceSkipReason = - | "unsupported_event" - | "event_without_diff_trace" - | "invalid_payload" - | "event_name_mismatch" - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload" - | "missing_session_id"; - -export type ClaudeDiffTraceDerivationResult = - | { - status: "derived"; - payload: ClaudeDiffTracePayload; - } - | { - status: "skipped"; - reason: ClaudeDiffTraceSkipReason; - }; - -export type ClaudeHookPayloadParseResult = - | { - status: "ok"; - payload: unknown; - } - | { - status: "error"; - message: string; - }; - -export type DeriveClaudeDiffTraceInput = { - eventName: string; - payload: unknown; - now?: () => number; - toolVersion?: string | null; -}; - -type JsonObject = Record; - -type DiffBuildResult = - | { - status: "built"; - diff: string; - } - | { - status: "skipped"; - reason: - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload"; - }; - -const CLAUDE_MODEL_ID_PREFIX = "claude/"; - -export function isClaudeAgentTraceEventName( - value: string, -): value is ClaudeAgentTraceEventName { - return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( - value as ClaudeAgentTraceEventName, - ); -} - -export function parseClaudeHookPayloadJson( - input: string, -): ClaudeHookPayloadParseResult { - try { - return { - status: "ok", - payload: JSON.parse(input), - }; - } catch (error) { - return { - status: "error", - message: error instanceof Error ? error.message : String(error), - }; - } -} - -export function deriveClaudeDiffTracePayload( - input: DeriveClaudeDiffTraceInput, -): ClaudeDiffTraceDerivationResult { - if (!isClaudeAgentTraceEventName(input.eventName)) { - return skipped("unsupported_event"); - } - - if (input.eventName !== "PostToolUse") { - return skipped("event_without_diff_trace"); - } - - const payload = asObject(input.payload); - if (payload === undefined) { - return skipped("invalid_payload"); - } - - const payloadEventName = stringField(payload, "hook_event_name"); - if (payloadEventName !== undefined && payloadEventName !== input.eventName) { - return skipped("event_name_mismatch"); - } - - const diffResult = buildClaudePostToolUseDiff(payload); - if (diffResult.status === "skipped") { - return skipped(diffResult.reason); - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - if (sessionId === undefined) { - return skipped("missing_session_id"); - } - - return { - status: "derived", - payload: { - sessionID: sessionId, - diff: diffResult.diff, - time: currentTimeMs(input.now), - tool_name: "claude", - tool_version: extractClaudeToolVersion(input.toolVersion, payload), - }, - }; -} - -export function normalizeClaudeModelId(model: string): string | undefined { - const normalized = model.trim(); - if (normalized.length === 0) { - return undefined; - } - - if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { - return normalized; - } - - return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; -} - -function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { - const toolName = stringField(payload, "tool_name"); - if (toolName === "Write") { - return buildWriteCreateDiff(payload); - } - - if (toolName === "Edit") { - return buildEditStructuredPatchDiff(payload); - } - - return skipped("unsupported_tool"); -} - -function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_write_payload"); - } - - const originalFile = valueField( - toolResponse, - "originalFile", - "original_file", - ); - if (originalFile !== null) { - return skipped("unsupported_write_payload"); - } - - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - stringField(toolResponse, "file_path", "filePath"), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const content = stringValueField(toolInput, "content", "newFile", "new_file"); - if (content === undefined) { - return skipped("missing_file_content"); - } - - return { - status: "built", - diff: renderWriteCreateDiff(filePath, content), - }; -} - -function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_edit_payload"); - } - - const structuredPatch = valueField( - toolResponse, - "structuredPatch", - "structured_patch", - ); - if (structuredPatch === undefined || structuredPatch === null) { - return skipped("unsupported_edit_payload"); - } - - const patchObject = asObject(structuredPatch); - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - (patchObject === undefined - ? undefined - : stringField(patchObject, "file_path", "filePath", "path")), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const hunkValues = structuredPatchHunks(structuredPatch); - const renderedHunks = hunkValues - .map(renderStructuredPatchHunk) - .filter((hunk): hunk is string => hunk !== undefined); - - if (renderedHunks.length === 0) { - return skipped("unsupported_edit_payload"); - } - - return { - status: "built", - diff: renderEditStructuredPatchDiff(filePath, renderedHunks), - }; -} - -function renderWriteCreateDiff(filePath: string, content: string): string { - const contentLines = splitFileContent(content); - const diffLines = [ - `diff --git a/${filePath} b/${filePath}`, - "new file mode 100644", - "--- /dev/null", - `+++ b/${filePath}`, - ]; - - if (contentLines.length > 0) { - diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); - for (const line of contentLines) { - diffLines.push(`+${line}`); - } - } - - return `${diffLines.join("\n")}\n`; -} - -function renderEditStructuredPatchDiff( - filePath: string, - renderedHunks: string[], -): string { - return `${[ - `Index: ${filePath}`, - "===================================================================", - `--- a/${filePath}`, - `+++ b/${filePath}`, - ...renderedHunks, - ].join("\n")}\n`; -} - -function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { - const hunk = asObject(hunkValue); - if (hunk === undefined) { - return undefined; - } - - const lines = arrayField(hunk, "lines", "body", "changes") - ?.map(renderStructuredPatchLine) - .filter((line): line is string => line !== undefined); - if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { - return undefined; - } - - const oldStart = numericField( - hunk, - "oldStart", - "old_start", - "oldLine", - "old_line", - ); - const newStart = numericField( - hunk, - "newStart", - "new_start", - "newLine", - "new_line", - ); - if (oldStart === undefined || newStart === undefined) { - return undefined; - } - - const oldCount = - numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? - countOldHunkLines(lines); - const newCount = - numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? - countNewHunkLines(lines); - - return [ - `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, - ...lines, - ].join("\n"); -} - -function renderStructuredPatchLine(lineValue: unknown): string | undefined { - if (typeof lineValue === "string") { - if ( - lineValue.startsWith("+") || - lineValue.startsWith("-") || - lineValue.startsWith(" ") || - lineValue.startsWith("\\") - ) { - return lineValue; - } - - return ` ${lineValue}`; - } - - const line = asObject(lineValue); - if (line === undefined) { - return undefined; - } - - const content = stringValueField(line, "content", "text", "value"); - if (content === undefined) { - return undefined; - } - - const kind = stringField(line, "kind", "type", "operation", "change"); - if ( - kind === "context" || - kind === "unchanged" || - kind === "equal" || - kind === " " - ) { - return ` ${content}`; - } - - if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { - return `+${content}`; - } - - if ( - kind === "removed" || - kind === "remove" || - kind === "delete" || - kind === "-" - ) { - return `-${content}`; - } - - return undefined; -} - -function structuredPatchHunks(structuredPatch: unknown): unknown[] { - if (Array.isArray(structuredPatch)) { - return structuredPatch; - } - - const patchObject = asObject(structuredPatch); - if (patchObject === undefined) { - return []; - } - - const hunks = arrayField(patchObject, "hunks", "changes"); - if (hunks !== undefined) { - return hunks; - } - - if (arrayField(patchObject, "lines", "body") !== undefined) { - return [patchObject]; - } - - return []; -} - -function splitFileContent(content: string): string[] { - const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (normalizedContent.length === 0) { - return []; - } - - if (normalizedContent.endsWith("\n")) { - return normalizedContent.slice(0, -1).split("\n"); - } - - return normalizedContent.split("\n"); -} - -function extractDirectPayloadModel(payload: JsonObject): string | undefined { - const directModel = stringField(payload, "model", "model_id", "modelId"); - if (directModel !== undefined) { - return directModel; - } - - const modelObject = asObject(payload.model); - if (modelObject === undefined) { - return undefined; - } - - return stringField(modelObject, "id", "model", "name"); -} - -function extractClaudeToolVersion( - inputToolVersion: string | null | undefined, - payload: JsonObject, -): string | null { - for (const value of [ - inputToolVersion, - payload.tool_version, - payload.claude_version, - payload.version, - ]) { - const normalized = normalizeOptionalVersion(value); - if (normalized !== undefined) { - return normalized; - } - } - - return null; -} - -function normalizeOptionalVersion(value: unknown): string | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null) { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const normalized = value.trim(); - return normalized.length === 0 ? null : normalized; -} - -function normalizePatchPath( - filePath: string | undefined, - cwd: string | undefined, -): string | undefined { - if (filePath === undefined) { - return undefined; - } - - let normalized = filePath.trim(); - if (normalized.length === 0) { - return undefined; - } - - if ( - cwd !== undefined && - path.isAbsolute(normalized) && - path.isAbsolute(cwd.trim()) - ) { - const relativePath = path.relative(cwd.trim(), normalized); - if ( - relativePath.length > 0 && - !relativePath.startsWith("..") && - !path.isAbsolute(relativePath) - ) { - normalized = relativePath; - } - } - - normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); - return normalized.length === 0 || normalized === "." ? undefined : normalized; -} - -function hasTouchedLine(lines: string[]): boolean { - return lines.some((line) => line.startsWith("+") || line.startsWith("-")); -} - -function countOldHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) - .length; -} - -function countNewHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) - .length; -} - -function currentTimeMs(now: (() => number) | undefined): number { - const value = now === undefined ? Date.now() : now(); - return Number.isFinite(value) ? Math.trunc(value) : Date.now(); -} - -function asObject(value: unknown): JsonObject | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as JsonObject) - : undefined; -} - -function stringField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "string") { - continue; - } - - const normalized = value.trim(); - if (normalized.length > 0) { - return normalized; - } - } - - return undefined; -} - -function stringValueField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value === "string") { - return value; - } - } - - return undefined; -} - -function numericField( - object: JsonObject, - ...keys: string[] -): number | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "number") { - continue; - } - - if (Number.isInteger(value) && value >= 0) { - return value; - } - } - - return undefined; -} - -function arrayField( - object: JsonObject, - ...keys: string[] -): unknown[] | undefined { - for (const key of keys) { - const value = object[key]; - if (Array.isArray(value)) { - return value; - } - } - - return undefined; -} - -function valueField(object: JsonObject, ...keys: string[]): unknown { - for (const key of keys) { - if (Object.hasOwn(object, key)) { - return object[key]; - } - } - - return undefined; -} - -function skipped( - reason: T, -): { - status: "skipped"; - reason: T; -} { - return { - status: "skipped", - reason, - }; -} - -// ─── Runtime: child-process spawn infrastructure ─────────────────────── - -/** - * Injectable spawn function signature used by the Claude hook runtime. - * Takes a command, arguments, stdin input, and optional cwd; resolves with - * the exit code and signal (null for normal exits). - */ -export type SpawnFn = ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, -) => Promise<{ code: number | null; signal: string | null }>; - -/** - * Real spawn implementation using `child_process.spawn`. - */ -export function createSpawnFn(): SpawnFn { - return ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, - ): Promise<{ code: number | null; signal: string | null }> => { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options?.cwd, - stdio: ["pipe", "ignore", "inherit"], - }); - - child.on("error", reject); - child.on("close", (code: number | null, signal: string | null) => { - resolve({ code, signal }); - }); - child.stdin.end(input); - }); - }; -} - -/** - * Read the entire contents of STDIN as a string. - * Returns an empty string when STDIN is a TTY (no piped data). - */ -export function readStdin(): Promise { - return new Promise((resolve, reject) => { - if (process.stdin.isTTY) { - resolve(""); - return; - } - - const chunks: Buffer[] = []; - - process.stdin.on("data", (chunk: Buffer) => { - chunks.push(chunk); - }); - - process.stdin.on("end", () => { - resolve(Buffer.concat(chunks).toString("utf-8")); - }); - - process.stdin.on("error", reject); - process.stdin.resume(); - }); -} - -// ─── Runtime: Claude hook event orchestration ────────────────────────── - -/** - * Context passed to the Claude hook runtime for external dependencies. - */ -export type ClaudeHookRuntimeContext = { - /** Spawn function (real or mock) used for child-process forwarding. */ - spawn: SpawnFn; - /** Optional working directory forwarded to spawned processes. */ - cwd?: string; - /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ - now?: () => number; -}; - -/** - * Run the Claude hook runtime for a single hook event. - * - * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a - * normalized session-model payload to `sce hooks session-model` (best-effort). - * - `PostToolUse`: Derives a diff-trace payload and forwards it to - * `sce hooks diff-trace` (best-effort). Model attribution is resolved by - * Rust from `session_models`; TypeScript does not look up the model. - * - Other events: No-op (no raw capture forwarding). - * - * All forwarding errors are caught and logged to stderr without failing the - * Claude hook. - * - * @param eventName - Validated Claude hook event name - * @param rawJson - Raw JSON payload read from STDIN - * @param context - Injectable dependencies - */ -export async function runClaudeHookRuntime( - eventName: string, - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - if (eventName === "SessionStart") { - await handleSessionStart(rawJson, context); - return; - } - - // For PostToolUse, attempt best-effort diff-trace forwarding - if (eventName !== "PostToolUse") { - return; - } - - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const derivation = deriveClaudeDiffTracePayload({ - eventName, - payload: parseResult.payload, - now: context.now, - }); - - if (derivation.status !== "derived") { - return; - } - - await context.spawn( - "sce", - ["hooks", "diff-trace"], - `${JSON.stringify(derivation.payload)}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function handleSessionStart( - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const payload = asObject(parseResult.payload); - if (payload === undefined) { - return; - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - const modelId = extractDirectPayloadModel(payload); - if (sessionId === undefined || modelId === undefined) { - return; - } - - await context.spawn( - "sce", - ["hooks", "session-model"], - `${JSON.stringify({ - sessionID: sessionId, - time: currentTimeMs(context.now), - model_id: normalizeClaudeModelId(modelId), - tool_name: "claude", - tool_version: extractClaudeToolVersion(undefined, payload), - })}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. - * - * - Reads the event name from `process.argv[2]`. - * - Reads the hook JSON payload from STDIN. - * - Delegates to {@link runClaudeHookRuntime}. - * - Exits with code 1 on missing/invalid event name or stdin read failure. - * - Exits with code 0 otherwise (internal forwarding errors are best-effort - * and do not change the exit code). - */ -export async function main(): Promise { - const eventName = process.argv[2]; - - if (!eventName) { - console.error("Usage: sce-agent-trace.ts "); - process.exit(1); - } - - if (!isClaudeAgentTraceEventName(eventName)) { - console.error(`Unknown Claude hook event: ${eventName}`); - process.exit(1); - } - - let stdinContent: string; - try { - stdinContent = await readStdin(); - } catch (error) { - console.error( - `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } - - try { - await runClaudeHookRuntime(eventName, stdinContent, { - spawn: createSpawnFn(), - }); - } catch (error) { - console.error( - `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } -} - -// Allow direct execution: `bun run .../sce-agent-trace.ts ` -if (import.meta.main) { - main(); -} diff --git a/.claude/settings.json b/.claude/settings.json index 98622696..65b949d2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,24 +5,10 @@ "hooks": [ { "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "SessionStart" - ] - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "UserPromptSubmit" + "hooks", + "session-model" ] } ] @@ -34,28 +20,14 @@ "hooks": [ { "type": "command", - "command": "bun", - "args": [ - ".claude/plugins/sce-agent-trace.ts", - "PostToolUse" - ] - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bun", + "command": "sce", "args": [ - ".claude/plugins/sce-agent-trace.ts", - "Stop" + "hooks", + "diff-trace" ] } ] } ] } -} \ No newline at end of file +} diff --git a/config/.claude/plugins/sce-agent-trace.ts b/config/.claude/plugins/sce-agent-trace.ts deleted file mode 100644 index b7a3ac75..00000000 --- a/config/.claude/plugins/sce-agent-trace.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; - -export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ - "SessionStart", - "UserPromptSubmit", - "PostToolUse", - "Stop", -] as const; - -export type ClaudeAgentTraceEventName = - (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; - -export type ClaudeDiffTracePayload = { - sessionID: string; - diff: string; - time: number; - model_id?: string; - tool_name: "claude"; - tool_version: string | null; -}; - -export type ClaudeDiffTraceSkipReason = - | "unsupported_event" - | "event_without_diff_trace" - | "invalid_payload" - | "event_name_mismatch" - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload" - | "missing_session_id"; - -export type ClaudeDiffTraceDerivationResult = - | { - status: "derived"; - payload: ClaudeDiffTracePayload; - } - | { - status: "skipped"; - reason: ClaudeDiffTraceSkipReason; - }; - -export type ClaudeHookPayloadParseResult = - | { - status: "ok"; - payload: unknown; - } - | { - status: "error"; - message: string; - }; - -export type DeriveClaudeDiffTraceInput = { - eventName: string; - payload: unknown; - now?: () => number; - toolVersion?: string | null; -}; - -type JsonObject = Record; - -type DiffBuildResult = - | { - status: "built"; - diff: string; - } - | { - status: "skipped"; - reason: - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload"; - }; - -const CLAUDE_MODEL_ID_PREFIX = "claude/"; - -export function isClaudeAgentTraceEventName( - value: string, -): value is ClaudeAgentTraceEventName { - return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( - value as ClaudeAgentTraceEventName, - ); -} - -export function parseClaudeHookPayloadJson( - input: string, -): ClaudeHookPayloadParseResult { - try { - return { - status: "ok", - payload: JSON.parse(input), - }; - } catch (error) { - return { - status: "error", - message: error instanceof Error ? error.message : String(error), - }; - } -} - -export function deriveClaudeDiffTracePayload( - input: DeriveClaudeDiffTraceInput, -): ClaudeDiffTraceDerivationResult { - if (!isClaudeAgentTraceEventName(input.eventName)) { - return skipped("unsupported_event"); - } - - if (input.eventName !== "PostToolUse") { - return skipped("event_without_diff_trace"); - } - - const payload = asObject(input.payload); - if (payload === undefined) { - return skipped("invalid_payload"); - } - - const payloadEventName = stringField(payload, "hook_event_name"); - if (payloadEventName !== undefined && payloadEventName !== input.eventName) { - return skipped("event_name_mismatch"); - } - - const diffResult = buildClaudePostToolUseDiff(payload); - if (diffResult.status === "skipped") { - return skipped(diffResult.reason); - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - if (sessionId === undefined) { - return skipped("missing_session_id"); - } - - return { - status: "derived", - payload: { - sessionID: sessionId, - diff: diffResult.diff, - time: currentTimeMs(input.now), - tool_name: "claude", - tool_version: extractClaudeToolVersion(input.toolVersion, payload), - }, - }; -} - -export function normalizeClaudeModelId(model: string): string | undefined { - const normalized = model.trim(); - if (normalized.length === 0) { - return undefined; - } - - if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { - return normalized; - } - - return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; -} - -function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { - const toolName = stringField(payload, "tool_name"); - if (toolName === "Write") { - return buildWriteCreateDiff(payload); - } - - if (toolName === "Edit") { - return buildEditStructuredPatchDiff(payload); - } - - return skipped("unsupported_tool"); -} - -function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_write_payload"); - } - - const originalFile = valueField( - toolResponse, - "originalFile", - "original_file", - ); - if (originalFile !== null) { - return skipped("unsupported_write_payload"); - } - - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - stringField(toolResponse, "file_path", "filePath"), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const content = stringValueField(toolInput, "content", "newFile", "new_file"); - if (content === undefined) { - return skipped("missing_file_content"); - } - - return { - status: "built", - diff: renderWriteCreateDiff(filePath, content), - }; -} - -function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_edit_payload"); - } - - const structuredPatch = valueField( - toolResponse, - "structuredPatch", - "structured_patch", - ); - if (structuredPatch === undefined || structuredPatch === null) { - return skipped("unsupported_edit_payload"); - } - - const patchObject = asObject(structuredPatch); - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - (patchObject === undefined - ? undefined - : stringField(patchObject, "file_path", "filePath", "path")), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const hunkValues = structuredPatchHunks(structuredPatch); - const renderedHunks = hunkValues - .map(renderStructuredPatchHunk) - .filter((hunk): hunk is string => hunk !== undefined); - - if (renderedHunks.length === 0) { - return skipped("unsupported_edit_payload"); - } - - return { - status: "built", - diff: renderEditStructuredPatchDiff(filePath, renderedHunks), - }; -} - -function renderWriteCreateDiff(filePath: string, content: string): string { - const contentLines = splitFileContent(content); - const diffLines = [ - `diff --git a/${filePath} b/${filePath}`, - "new file mode 100644", - "--- /dev/null", - `+++ b/${filePath}`, - ]; - - if (contentLines.length > 0) { - diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); - for (const line of contentLines) { - diffLines.push(`+${line}`); - } - } - - return `${diffLines.join("\n")}\n`; -} - -function renderEditStructuredPatchDiff( - filePath: string, - renderedHunks: string[], -): string { - return `${[ - `Index: ${filePath}`, - "===================================================================", - `--- a/${filePath}`, - `+++ b/${filePath}`, - ...renderedHunks, - ].join("\n")}\n`; -} - -function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { - const hunk = asObject(hunkValue); - if (hunk === undefined) { - return undefined; - } - - const lines = arrayField(hunk, "lines", "body", "changes") - ?.map(renderStructuredPatchLine) - .filter((line): line is string => line !== undefined); - if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { - return undefined; - } - - const oldStart = numericField( - hunk, - "oldStart", - "old_start", - "oldLine", - "old_line", - ); - const newStart = numericField( - hunk, - "newStart", - "new_start", - "newLine", - "new_line", - ); - if (oldStart === undefined || newStart === undefined) { - return undefined; - } - - const oldCount = - numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? - countOldHunkLines(lines); - const newCount = - numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? - countNewHunkLines(lines); - - return [ - `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, - ...lines, - ].join("\n"); -} - -function renderStructuredPatchLine(lineValue: unknown): string | undefined { - if (typeof lineValue === "string") { - if ( - lineValue.startsWith("+") || - lineValue.startsWith("-") || - lineValue.startsWith(" ") || - lineValue.startsWith("\\") - ) { - return lineValue; - } - - return ` ${lineValue}`; - } - - const line = asObject(lineValue); - if (line === undefined) { - return undefined; - } - - const content = stringValueField(line, "content", "text", "value"); - if (content === undefined) { - return undefined; - } - - const kind = stringField(line, "kind", "type", "operation", "change"); - if ( - kind === "context" || - kind === "unchanged" || - kind === "equal" || - kind === " " - ) { - return ` ${content}`; - } - - if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { - return `+${content}`; - } - - if ( - kind === "removed" || - kind === "remove" || - kind === "delete" || - kind === "-" - ) { - return `-${content}`; - } - - return undefined; -} - -function structuredPatchHunks(structuredPatch: unknown): unknown[] { - if (Array.isArray(structuredPatch)) { - return structuredPatch; - } - - const patchObject = asObject(structuredPatch); - if (patchObject === undefined) { - return []; - } - - const hunks = arrayField(patchObject, "hunks", "changes"); - if (hunks !== undefined) { - return hunks; - } - - if (arrayField(patchObject, "lines", "body") !== undefined) { - return [patchObject]; - } - - return []; -} - -function splitFileContent(content: string): string[] { - const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (normalizedContent.length === 0) { - return []; - } - - if (normalizedContent.endsWith("\n")) { - return normalizedContent.slice(0, -1).split("\n"); - } - - return normalizedContent.split("\n"); -} - -function extractDirectPayloadModel(payload: JsonObject): string | undefined { - const directModel = stringField(payload, "model", "model_id", "modelId"); - if (directModel !== undefined) { - return directModel; - } - - const modelObject = asObject(payload.model); - if (modelObject === undefined) { - return undefined; - } - - return stringField(modelObject, "id", "model", "name"); -} - -function extractClaudeToolVersion( - inputToolVersion: string | null | undefined, - payload: JsonObject, -): string | null { - for (const value of [ - inputToolVersion, - payload.tool_version, - payload.claude_version, - payload.version, - ]) { - const normalized = normalizeOptionalVersion(value); - if (normalized !== undefined) { - return normalized; - } - } - - return null; -} - -function normalizeOptionalVersion(value: unknown): string | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null) { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const normalized = value.trim(); - return normalized.length === 0 ? null : normalized; -} - -function normalizePatchPath( - filePath: string | undefined, - cwd: string | undefined, -): string | undefined { - if (filePath === undefined) { - return undefined; - } - - let normalized = filePath.trim(); - if (normalized.length === 0) { - return undefined; - } - - if ( - cwd !== undefined && - path.isAbsolute(normalized) && - path.isAbsolute(cwd.trim()) - ) { - const relativePath = path.relative(cwd.trim(), normalized); - if ( - relativePath.length > 0 && - !relativePath.startsWith("..") && - !path.isAbsolute(relativePath) - ) { - normalized = relativePath; - } - } - - normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); - return normalized.length === 0 || normalized === "." ? undefined : normalized; -} - -function hasTouchedLine(lines: string[]): boolean { - return lines.some((line) => line.startsWith("+") || line.startsWith("-")); -} - -function countOldHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) - .length; -} - -function countNewHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) - .length; -} - -function currentTimeMs(now: (() => number) | undefined): number { - const value = now === undefined ? Date.now() : now(); - return Number.isFinite(value) ? Math.trunc(value) : Date.now(); -} - -function asObject(value: unknown): JsonObject | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as JsonObject) - : undefined; -} - -function stringField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "string") { - continue; - } - - const normalized = value.trim(); - if (normalized.length > 0) { - return normalized; - } - } - - return undefined; -} - -function stringValueField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value === "string") { - return value; - } - } - - return undefined; -} - -function numericField( - object: JsonObject, - ...keys: string[] -): number | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "number") { - continue; - } - - if (Number.isInteger(value) && value >= 0) { - return value; - } - } - - return undefined; -} - -function arrayField( - object: JsonObject, - ...keys: string[] -): unknown[] | undefined { - for (const key of keys) { - const value = object[key]; - if (Array.isArray(value)) { - return value; - } - } - - return undefined; -} - -function valueField(object: JsonObject, ...keys: string[]): unknown { - for (const key of keys) { - if (Object.hasOwn(object, key)) { - return object[key]; - } - } - - return undefined; -} - -function skipped( - reason: T, -): { - status: "skipped"; - reason: T; -} { - return { - status: "skipped", - reason, - }; -} - -// ─── Runtime: child-process spawn infrastructure ─────────────────────── - -/** - * Injectable spawn function signature used by the Claude hook runtime. - * Takes a command, arguments, stdin input, and optional cwd; resolves with - * the exit code and signal (null for normal exits). - */ -export type SpawnFn = ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, -) => Promise<{ code: number | null; signal: string | null }>; - -/** - * Real spawn implementation using `child_process.spawn`. - */ -export function createSpawnFn(): SpawnFn { - return ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, - ): Promise<{ code: number | null; signal: string | null }> => { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options?.cwd, - stdio: ["pipe", "ignore", "inherit"], - }); - - child.on("error", reject); - child.on("close", (code: number | null, signal: string | null) => { - resolve({ code, signal }); - }); - child.stdin.end(input); - }); - }; -} - -/** - * Read the entire contents of STDIN as a string. - * Returns an empty string when STDIN is a TTY (no piped data). - */ -export function readStdin(): Promise { - return new Promise((resolve, reject) => { - if (process.stdin.isTTY) { - resolve(""); - return; - } - - const chunks: Buffer[] = []; - - process.stdin.on("data", (chunk: Buffer) => { - chunks.push(chunk); - }); - - process.stdin.on("end", () => { - resolve(Buffer.concat(chunks).toString("utf-8")); - }); - - process.stdin.on("error", reject); - process.stdin.resume(); - }); -} - -// ─── Runtime: Claude hook event orchestration ────────────────────────── - -/** - * Context passed to the Claude hook runtime for external dependencies. - */ -export type ClaudeHookRuntimeContext = { - /** Spawn function (real or mock) used for child-process forwarding. */ - spawn: SpawnFn; - /** Optional working directory forwarded to spawned processes. */ - cwd?: string; - /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ - now?: () => number; -}; - -/** - * Run the Claude hook runtime for a single hook event. - * - * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a - * normalized session-model payload to `sce hooks session-model` (best-effort). - * - `PostToolUse`: Derives a diff-trace payload and forwards it to - * `sce hooks diff-trace` (best-effort). Model attribution is resolved by - * Rust from `session_models`; TypeScript does not look up the model. - * - Other events: No-op (no raw capture forwarding). - * - * All forwarding errors are caught and logged to stderr without failing the - * Claude hook. - * - * @param eventName - Validated Claude hook event name - * @param rawJson - Raw JSON payload read from STDIN - * @param context - Injectable dependencies - */ -export async function runClaudeHookRuntime( - eventName: string, - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - if (eventName === "SessionStart") { - await handleSessionStart(rawJson, context); - return; - } - - // For PostToolUse, attempt best-effort diff-trace forwarding - if (eventName !== "PostToolUse") { - return; - } - - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const derivation = deriveClaudeDiffTracePayload({ - eventName, - payload: parseResult.payload, - now: context.now, - }); - - if (derivation.status !== "derived") { - return; - } - - await context.spawn( - "sce", - ["hooks", "diff-trace"], - `${JSON.stringify(derivation.payload)}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function handleSessionStart( - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const payload = asObject(parseResult.payload); - if (payload === undefined) { - return; - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - const modelId = extractDirectPayloadModel(payload); - if (sessionId === undefined || modelId === undefined) { - return; - } - - await context.spawn( - "sce", - ["hooks", "session-model"], - `${JSON.stringify({ - sessionID: sessionId, - time: currentTimeMs(context.now), - model_id: normalizeClaudeModelId(modelId), - tool_name: "claude", - tool_version: extractClaudeToolVersion(undefined, payload), - })}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. - * - * - Reads the event name from `process.argv[2]`. - * - Reads the hook JSON payload from STDIN. - * - Delegates to {@link runClaudeHookRuntime}. - * - Exits with code 1 on missing/invalid event name or stdin read failure. - * - Exits with code 0 otherwise (internal forwarding errors are best-effort - * and do not change the exit code). - */ -export async function main(): Promise { - const eventName = process.argv[2]; - - if (!eventName) { - console.error("Usage: sce-agent-trace.ts "); - process.exit(1); - } - - if (!isClaudeAgentTraceEventName(eventName)) { - console.error(`Unknown Claude hook event: ${eventName}`); - process.exit(1); - } - - let stdinContent: string; - try { - stdinContent = await readStdin(); - } catch (error) { - console.error( - `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } - - try { - await runClaudeHookRuntime(eventName, stdinContent, { - spawn: createSpawnFn(), - }); - } catch (error) { - console.error( - `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } -} - -// Allow direct execution: `bun run .../sce-agent-trace.ts ` -if (import.meta.main) { - main(); -} diff --git a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts deleted file mode 100644 index e2fa484b..00000000 --- a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { deriveClaudeDiffTracePayload } from "./claude-sce-agent-trace-plugin.ts"; - -const FIXED_TIME = 1700000000000; -const FIXED_TOOL_VERSION = "test-claude-version"; -const EXPECTED_SCENARIOS = [ - "write_create_simple", - "write_create_empty", - "write_create_no_newline", - "write_create_multiline", - "edit_single_hunk", - "edit_multi_hunk", - "edit_only_additions", - "edit_only_deletions", -] as const; - -type ClaudePostToolUseFixture = { - session_id: string; -}; - -const fixtureRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../../cli/src/services/structured_patch/fixtures", -); - -function discoverFixtureScenarios(): string[] { - return readdirSync(fixtureRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); -} - -function orderedFixtureScenarios(): string[] { - const discovered = discoverFixtureScenarios(); - const discoveredSet = new Set(discovered); - const expectedSet = new Set(EXPECTED_SCENARIOS); - const missing = EXPECTED_SCENARIOS.filter((name) => !discoveredSet.has(name)); - const extra = discovered.filter((name) => !expectedSet.has(name)); - - if (missing.length > 0 || extra.length > 0) { - throw new Error( - `Unexpected Claude diff-creation fixtures. Missing: ${missing.join(", ") || "none"}. Extra: ${extra.join(", ") || "none"}.`, - ); - } - - return EXPECTED_SCENARIOS.filter((name) => discoveredSet.has(name)); -} - -function loadFixture(name: string): { - input: ClaudePostToolUseFixture; - expected: string; -} { - const dir = path.join(fixtureRoot, name); - const input = JSON.parse( - readFileSync(path.join(dir, "claude-post-tool-use.json"), "utf-8"), - ) as unknown; - const expected = readFileSync(path.join(dir, "expected.patch"), "utf-8"); - - if (!hasSessionId(input)) { - throw new Error(`${name} fixture is missing a string session_id`); - } - - return { input, expected }; -} - -function hasSessionId(value: unknown): value is ClaudePostToolUseFixture { - return ( - typeof value === "object" && - value !== null && - "session_id" in value && - typeof value.session_id === "string" - ); -} - -describe("deriveClaudeDiffTracePayload", () => { - for (const name of orderedFixtureScenarios()) { - test(`claude_derivation/${name}`, () => { - const { input, expected } = loadFixture(name); - const result = deriveClaudeDiffTracePayload({ - eventName: "PostToolUse", - payload: input, - now: () => FIXED_TIME, - toolVersion: FIXED_TOOL_VERSION, - }); - - expect(result.status).toBe("derived"); - if (result.status !== "derived") { - throw new Error(`Expected ${name} fixture to derive a diff trace`); - } - - expect(result.payload.sessionID).toBe(input.session_id); - expect(result.payload.time).toBe(FIXED_TIME); - expect(result.payload.tool_name).toBe("claude"); - expect(result.payload.tool_version).toBe(FIXED_TOOL_VERSION); - expect(result.payload.diff).toBe(expected); - expect(Object.hasOwn(result.payload, "model_id")).toBe(false); - }); - } -}); diff --git a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts b/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts deleted file mode 100644 index b7a3ac75..00000000 --- a/config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; - -export const CLAUDE_AGENT_TRACE_EVENT_NAMES = [ - "SessionStart", - "UserPromptSubmit", - "PostToolUse", - "Stop", -] as const; - -export type ClaudeAgentTraceEventName = - (typeof CLAUDE_AGENT_TRACE_EVENT_NAMES)[number]; - -export type ClaudeDiffTracePayload = { - sessionID: string; - diff: string; - time: number; - model_id?: string; - tool_name: "claude"; - tool_version: string | null; -}; - -export type ClaudeDiffTraceSkipReason = - | "unsupported_event" - | "event_without_diff_trace" - | "invalid_payload" - | "event_name_mismatch" - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload" - | "missing_session_id"; - -export type ClaudeDiffTraceDerivationResult = - | { - status: "derived"; - payload: ClaudeDiffTracePayload; - } - | { - status: "skipped"; - reason: ClaudeDiffTraceSkipReason; - }; - -export type ClaudeHookPayloadParseResult = - | { - status: "ok"; - payload: unknown; - } - | { - status: "error"; - message: string; - }; - -export type DeriveClaudeDiffTraceInput = { - eventName: string; - payload: unknown; - now?: () => number; - toolVersion?: string | null; -}; - -type JsonObject = Record; - -type DiffBuildResult = - | { - status: "built"; - diff: string; - } - | { - status: "skipped"; - reason: - | "unsupported_tool" - | "unsupported_write_payload" - | "missing_file_path" - | "missing_file_content" - | "unsupported_edit_payload"; - }; - -const CLAUDE_MODEL_ID_PREFIX = "claude/"; - -export function isClaudeAgentTraceEventName( - value: string, -): value is ClaudeAgentTraceEventName { - return CLAUDE_AGENT_TRACE_EVENT_NAMES.includes( - value as ClaudeAgentTraceEventName, - ); -} - -export function parseClaudeHookPayloadJson( - input: string, -): ClaudeHookPayloadParseResult { - try { - return { - status: "ok", - payload: JSON.parse(input), - }; - } catch (error) { - return { - status: "error", - message: error instanceof Error ? error.message : String(error), - }; - } -} - -export function deriveClaudeDiffTracePayload( - input: DeriveClaudeDiffTraceInput, -): ClaudeDiffTraceDerivationResult { - if (!isClaudeAgentTraceEventName(input.eventName)) { - return skipped("unsupported_event"); - } - - if (input.eventName !== "PostToolUse") { - return skipped("event_without_diff_trace"); - } - - const payload = asObject(input.payload); - if (payload === undefined) { - return skipped("invalid_payload"); - } - - const payloadEventName = stringField(payload, "hook_event_name"); - if (payloadEventName !== undefined && payloadEventName !== input.eventName) { - return skipped("event_name_mismatch"); - } - - const diffResult = buildClaudePostToolUseDiff(payload); - if (diffResult.status === "skipped") { - return skipped(diffResult.reason); - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - if (sessionId === undefined) { - return skipped("missing_session_id"); - } - - return { - status: "derived", - payload: { - sessionID: sessionId, - diff: diffResult.diff, - time: currentTimeMs(input.now), - tool_name: "claude", - tool_version: extractClaudeToolVersion(input.toolVersion, payload), - }, - }; -} - -export function normalizeClaudeModelId(model: string): string | undefined { - const normalized = model.trim(); - if (normalized.length === 0) { - return undefined; - } - - if (normalized.startsWith(CLAUDE_MODEL_ID_PREFIX)) { - return normalized; - } - - return `${CLAUDE_MODEL_ID_PREFIX}${normalized}`; -} - -function buildClaudePostToolUseDiff(payload: JsonObject): DiffBuildResult { - const toolName = stringField(payload, "tool_name"); - if (toolName === "Write") { - return buildWriteCreateDiff(payload); - } - - if (toolName === "Edit") { - return buildEditStructuredPatchDiff(payload); - } - - return skipped("unsupported_tool"); -} - -function buildWriteCreateDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_write_payload"); - } - - const originalFile = valueField( - toolResponse, - "originalFile", - "original_file", - ); - if (originalFile !== null) { - return skipped("unsupported_write_payload"); - } - - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - stringField(toolResponse, "file_path", "filePath"), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const content = stringValueField(toolInput, "content", "newFile", "new_file"); - if (content === undefined) { - return skipped("missing_file_content"); - } - - return { - status: "built", - diff: renderWriteCreateDiff(filePath, content), - }; -} - -function buildEditStructuredPatchDiff(payload: JsonObject): DiffBuildResult { - const toolInput = asObject(payload.tool_input); - const toolResponse = asObject(payload.tool_response); - if (toolInput === undefined || toolResponse === undefined) { - return skipped("unsupported_edit_payload"); - } - - const structuredPatch = valueField( - toolResponse, - "structuredPatch", - "structured_patch", - ); - if (structuredPatch === undefined || structuredPatch === null) { - return skipped("unsupported_edit_payload"); - } - - const patchObject = asObject(structuredPatch); - const filePath = normalizePatchPath( - stringField(toolInput, "file_path", "filePath") ?? - (patchObject === undefined - ? undefined - : stringField(patchObject, "file_path", "filePath", "path")), - stringField(payload, "cwd"), - ); - if (filePath === undefined) { - return skipped("missing_file_path"); - } - - const hunkValues = structuredPatchHunks(structuredPatch); - const renderedHunks = hunkValues - .map(renderStructuredPatchHunk) - .filter((hunk): hunk is string => hunk !== undefined); - - if (renderedHunks.length === 0) { - return skipped("unsupported_edit_payload"); - } - - return { - status: "built", - diff: renderEditStructuredPatchDiff(filePath, renderedHunks), - }; -} - -function renderWriteCreateDiff(filePath: string, content: string): string { - const contentLines = splitFileContent(content); - const diffLines = [ - `diff --git a/${filePath} b/${filePath}`, - "new file mode 100644", - "--- /dev/null", - `+++ b/${filePath}`, - ]; - - if (contentLines.length > 0) { - diffLines.push(`@@ -0,0 +1,${contentLines.length} @@`); - for (const line of contentLines) { - diffLines.push(`+${line}`); - } - } - - return `${diffLines.join("\n")}\n`; -} - -function renderEditStructuredPatchDiff( - filePath: string, - renderedHunks: string[], -): string { - return `${[ - `Index: ${filePath}`, - "===================================================================", - `--- a/${filePath}`, - `+++ b/${filePath}`, - ...renderedHunks, - ].join("\n")}\n`; -} - -function renderStructuredPatchHunk(hunkValue: unknown): string | undefined { - const hunk = asObject(hunkValue); - if (hunk === undefined) { - return undefined; - } - - const lines = arrayField(hunk, "lines", "body", "changes") - ?.map(renderStructuredPatchLine) - .filter((line): line is string => line !== undefined); - if (lines === undefined || lines.length === 0 || !hasTouchedLine(lines)) { - return undefined; - } - - const oldStart = numericField( - hunk, - "oldStart", - "old_start", - "oldLine", - "old_line", - ); - const newStart = numericField( - hunk, - "newStart", - "new_start", - "newLine", - "new_line", - ); - if (oldStart === undefined || newStart === undefined) { - return undefined; - } - - const oldCount = - numericField(hunk, "oldCount", "old_count", "oldLines", "old_lines") ?? - countOldHunkLines(lines); - const newCount = - numericField(hunk, "newCount", "new_count", "newLines", "new_lines") ?? - countNewHunkLines(lines); - - return [ - `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, - ...lines, - ].join("\n"); -} - -function renderStructuredPatchLine(lineValue: unknown): string | undefined { - if (typeof lineValue === "string") { - if ( - lineValue.startsWith("+") || - lineValue.startsWith("-") || - lineValue.startsWith(" ") || - lineValue.startsWith("\\") - ) { - return lineValue; - } - - return ` ${lineValue}`; - } - - const line = asObject(lineValue); - if (line === undefined) { - return undefined; - } - - const content = stringValueField(line, "content", "text", "value"); - if (content === undefined) { - return undefined; - } - - const kind = stringField(line, "kind", "type", "operation", "change"); - if ( - kind === "context" || - kind === "unchanged" || - kind === "equal" || - kind === " " - ) { - return ` ${content}`; - } - - if (kind === "added" || kind === "add" || kind === "insert" || kind === "+") { - return `+${content}`; - } - - if ( - kind === "removed" || - kind === "remove" || - kind === "delete" || - kind === "-" - ) { - return `-${content}`; - } - - return undefined; -} - -function structuredPatchHunks(structuredPatch: unknown): unknown[] { - if (Array.isArray(structuredPatch)) { - return structuredPatch; - } - - const patchObject = asObject(structuredPatch); - if (patchObject === undefined) { - return []; - } - - const hunks = arrayField(patchObject, "hunks", "changes"); - if (hunks !== undefined) { - return hunks; - } - - if (arrayField(patchObject, "lines", "body") !== undefined) { - return [patchObject]; - } - - return []; -} - -function splitFileContent(content: string): string[] { - const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (normalizedContent.length === 0) { - return []; - } - - if (normalizedContent.endsWith("\n")) { - return normalizedContent.slice(0, -1).split("\n"); - } - - return normalizedContent.split("\n"); -} - -function extractDirectPayloadModel(payload: JsonObject): string | undefined { - const directModel = stringField(payload, "model", "model_id", "modelId"); - if (directModel !== undefined) { - return directModel; - } - - const modelObject = asObject(payload.model); - if (modelObject === undefined) { - return undefined; - } - - return stringField(modelObject, "id", "model", "name"); -} - -function extractClaudeToolVersion( - inputToolVersion: string | null | undefined, - payload: JsonObject, -): string | null { - for (const value of [ - inputToolVersion, - payload.tool_version, - payload.claude_version, - payload.version, - ]) { - const normalized = normalizeOptionalVersion(value); - if (normalized !== undefined) { - return normalized; - } - } - - return null; -} - -function normalizeOptionalVersion(value: unknown): string | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null) { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const normalized = value.trim(); - return normalized.length === 0 ? null : normalized; -} - -function normalizePatchPath( - filePath: string | undefined, - cwd: string | undefined, -): string | undefined { - if (filePath === undefined) { - return undefined; - } - - let normalized = filePath.trim(); - if (normalized.length === 0) { - return undefined; - } - - if ( - cwd !== undefined && - path.isAbsolute(normalized) && - path.isAbsolute(cwd.trim()) - ) { - const relativePath = path.relative(cwd.trim(), normalized); - if ( - relativePath.length > 0 && - !relativePath.startsWith("..") && - !path.isAbsolute(relativePath) - ) { - normalized = relativePath; - } - } - - normalized = normalized.replaceAll("\\", "/").replace(/^\.\/+/, ""); - return normalized.length === 0 || normalized === "." ? undefined : normalized; -} - -function hasTouchedLine(lines: string[]): boolean { - return lines.some((line) => line.startsWith("+") || line.startsWith("-")); -} - -function countOldHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("+") && !line.startsWith("\\")) - .length; -} - -function countNewHunkLines(lines: string[]): number { - return lines.filter((line) => !line.startsWith("-") && !line.startsWith("\\")) - .length; -} - -function currentTimeMs(now: (() => number) | undefined): number { - const value = now === undefined ? Date.now() : now(); - return Number.isFinite(value) ? Math.trunc(value) : Date.now(); -} - -function asObject(value: unknown): JsonObject | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as JsonObject) - : undefined; -} - -function stringField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "string") { - continue; - } - - const normalized = value.trim(); - if (normalized.length > 0) { - return normalized; - } - } - - return undefined; -} - -function stringValueField( - object: JsonObject, - ...keys: string[] -): string | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value === "string") { - return value; - } - } - - return undefined; -} - -function numericField( - object: JsonObject, - ...keys: string[] -): number | undefined { - for (const key of keys) { - const value = object[key]; - if (typeof value !== "number") { - continue; - } - - if (Number.isInteger(value) && value >= 0) { - return value; - } - } - - return undefined; -} - -function arrayField( - object: JsonObject, - ...keys: string[] -): unknown[] | undefined { - for (const key of keys) { - const value = object[key]; - if (Array.isArray(value)) { - return value; - } - } - - return undefined; -} - -function valueField(object: JsonObject, ...keys: string[]): unknown { - for (const key of keys) { - if (Object.hasOwn(object, key)) { - return object[key]; - } - } - - return undefined; -} - -function skipped( - reason: T, -): { - status: "skipped"; - reason: T; -} { - return { - status: "skipped", - reason, - }; -} - -// ─── Runtime: child-process spawn infrastructure ─────────────────────── - -/** - * Injectable spawn function signature used by the Claude hook runtime. - * Takes a command, arguments, stdin input, and optional cwd; resolves with - * the exit code and signal (null for normal exits). - */ -export type SpawnFn = ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, -) => Promise<{ code: number | null; signal: string | null }>; - -/** - * Real spawn implementation using `child_process.spawn`. - */ -export function createSpawnFn(): SpawnFn { - return ( - command: string, - args: readonly string[], - input: string, - options?: { cwd?: string }, - ): Promise<{ code: number | null; signal: string | null }> => { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options?.cwd, - stdio: ["pipe", "ignore", "inherit"], - }); - - child.on("error", reject); - child.on("close", (code: number | null, signal: string | null) => { - resolve({ code, signal }); - }); - child.stdin.end(input); - }); - }; -} - -/** - * Read the entire contents of STDIN as a string. - * Returns an empty string when STDIN is a TTY (no piped data). - */ -export function readStdin(): Promise { - return new Promise((resolve, reject) => { - if (process.stdin.isTTY) { - resolve(""); - return; - } - - const chunks: Buffer[] = []; - - process.stdin.on("data", (chunk: Buffer) => { - chunks.push(chunk); - }); - - process.stdin.on("end", () => { - resolve(Buffer.concat(chunks).toString("utf-8")); - }); - - process.stdin.on("error", reject); - process.stdin.resume(); - }); -} - -// ─── Runtime: Claude hook event orchestration ────────────────────────── - -/** - * Context passed to the Claude hook runtime for external dependencies. - */ -export type ClaudeHookRuntimeContext = { - /** Spawn function (real or mock) used for child-process forwarding. */ - spawn: SpawnFn; - /** Optional working directory forwarded to spawned processes. */ - cwd?: string; - /** Optional timestamp supplier for diff-trace derivation (defaults to Date.now). */ - now?: () => number; -}; - -/** - * Run the Claude hook runtime for a single hook event. - * - * - `SessionStart`: Extracts `session_id` + `model_id` and forwards a - * normalized session-model payload to `sce hooks session-model` (best-effort). - * - `PostToolUse`: Derives a diff-trace payload and forwards it to - * `sce hooks diff-trace` (best-effort). Model attribution is resolved by - * Rust from `session_models`; TypeScript does not look up the model. - * - Other events: No-op (no raw capture forwarding). - * - * All forwarding errors are caught and logged to stderr without failing the - * Claude hook. - * - * @param eventName - Validated Claude hook event name - * @param rawJson - Raw JSON payload read from STDIN - * @param context - Injectable dependencies - */ -export async function runClaudeHookRuntime( - eventName: string, - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - if (eventName === "SessionStart") { - await handleSessionStart(rawJson, context); - return; - } - - // For PostToolUse, attempt best-effort diff-trace forwarding - if (eventName !== "PostToolUse") { - return; - } - - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const derivation = deriveClaudeDiffTracePayload({ - eventName, - payload: parseResult.payload, - now: context.now, - }); - - if (derivation.status !== "derived") { - return; - } - - await context.spawn( - "sce", - ["hooks", "diff-trace"], - `${JSON.stringify(derivation.payload)}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] Diff-trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function handleSessionStart( - rawJson: string, - context: ClaudeHookRuntimeContext, -): Promise { - try { - const parseResult = parseClaudeHookPayloadJson(rawJson); - if (parseResult.status !== "ok") { - return; - } - - const payload = asObject(parseResult.payload); - if (payload === undefined) { - return; - } - - const sessionId = stringField(payload, "session_id", "sessionID"); - const modelId = extractDirectPayloadModel(payload); - if (sessionId === undefined || modelId === undefined) { - return; - } - - await context.spawn( - "sce", - ["hooks", "session-model"], - `${JSON.stringify({ - sessionID: sessionId, - time: currentTimeMs(context.now), - model_id: normalizeClaudeModelId(modelId), - tool_name: "claude", - tool_version: extractClaudeToolVersion(undefined, payload), - })}\n`, - { cwd: context.cwd }, - ); - } catch (error) { - console.error( - `[sce] SessionStart model attribution failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * Main entry point for `bun .claude/plugins/sce-agent-trace.ts `. - * - * - Reads the event name from `process.argv[2]`. - * - Reads the hook JSON payload from STDIN. - * - Delegates to {@link runClaudeHookRuntime}. - * - Exits with code 1 on missing/invalid event name or stdin read failure. - * - Exits with code 0 otherwise (internal forwarding errors are best-effort - * and do not change the exit code). - */ -export async function main(): Promise { - const eventName = process.argv[2]; - - if (!eventName) { - console.error("Usage: sce-agent-trace.ts "); - process.exit(1); - } - - if (!isClaudeAgentTraceEventName(eventName)) { - console.error(`Unknown Claude hook event: ${eventName}`); - process.exit(1); - } - - let stdinContent: string; - try { - stdinContent = await readStdin(); - } catch (error) { - console.error( - `Failed to read stdin: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } - - try { - await runClaudeHookRuntime(eventName, stdinContent, { - spawn: createSpawnFn(), - }); - } catch (error) { - console.error( - `[sce] Hook runtime error for ${eventName}: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } -} - -// Allow direct execution: `bun run .../sce-agent-trace.ts ` -if (import.meta.main) { - main(); -} diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 1d154d8f..9e9f1356 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -9,8 +9,6 @@ local bashPolicyPresetCatalogSource = bash_policy_presets.output.text local bashPolicyRuntimeSource = read("../lib/bash-policy-plugin/bash-policy/runtime.ts").text local opencodeBashPolicyPluginSource = read("../lib/bash-policy-plugin/opencode-bash-policy-plugin.ts").text local opencodeAgentTracePluginSource = read("../lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts").text -local claudeAgentTracePluginSource = read("../lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts").text - output { files { [".github/workflows/publish-tiles.yml"] { @@ -51,9 +49,6 @@ output { ["config/.claude/settings.json"] { text = claude.settings.rendered } - ["config/.claude/plugins/sce-agent-trace.ts"] { - text = claudeAgentTracePluginSource - } for (slug, document in opencode.skills) { ["config/.opencode/skills/\(slug)/SKILL.md"] { diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index b386929f..e519bd04 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -93,12 +93,13 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run `nix develop -c pkl eval -m . config/pkl/generate.pkl` after source edits, then `nix run .#pkl-check-generated`. - Completion evidence (2026-06-10): Replaced `claude-content.pkl` settings block: `SessionStart` routes to `sce hooks session-model`, `PostToolUse Write|Edit|MultiEdit|NotebookEdit` routes to `sce hooks diff-trace`, removed `UserPromptSubmit`/`Stop` hooks. Added `parse_claude_session_model_payload` to `cli/src/services/hooks/mod.rs` so the Rust `session-model` intake handles raw Claude `SessionStart` payloads (extracts `session_id`/`model_id`/`time`/`tool_version`, normalizes `model_id` with `claude/` prefix). Regenerated `config/.claude/settings.json`. `nix flake check` passed (all 4 checks green). `nix run .#pkl-check-generated` passed. -- [ ] T07: `Remove Claude TypeScript plugin source and generated outputs` (status:todo) - - Task ID: T07 - - Goal: Delete the now-obsolete Claude TypeScript agent-trace runtime and its TypeScript golden tests while preserving OpenCode TypeScript plugin/runtime code. - - Boundaries (in/out of scope): In - remove `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts`, its Bun test, generated `config/.claude/plugins/sce-agent-trace.ts`, generated root `.claude/plugins/sce-agent-trace.ts`, and references that assume a Claude plugin path exists. Out - `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts`, OpenCode generated plugins, bash-policy code. - - Done when: No repo-owned Claude `.claude/plugins` agent-trace TypeScript remains; config-lib package/test configuration no longer expects the deleted Claude test; generated output parity is clean after regeneration. - - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T09 full validation. +- [x] T07: `Remove Claude TypeScript plugin source and generated outputs` (status:done) + - Task ID: T07 + - Goal: Delete the now-obsolete Claude TypeScript agent-trace runtime and its TypeScript golden tests while preserving OpenCode TypeScript plugin/runtime code. + - Boundaries (in/out of scope): In - remove `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts`, its Bun test, generated `config/.claude/plugins/sce-agent-trace.ts`, generated root `.claude/plugins/sce-agent-trace.ts`, and references that assume a Claude plugin path exists. Out - `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts`, OpenCode generated plugins, bash-policy code. + - Done when: No repo-owned Claude `.claude/plugins` agent-trace TypeScript remains; config-lib package/test configuration no longer expects the deleted Claude test; generated output parity is clean after regeneration. + - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T09 full validation. + - Completion evidence (2026-06-10): Deleted `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts` (canonical source), `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts` (Bun test), `config/.claude/plugins/sce-agent-trace.ts` (generated), and `.claude/plugins/sce-agent-trace.ts` (root copy). Removed Claude plugin source read and output mapping from `config/pkl/generate.pkl`. Updated root `.claude/settings.json` to direct `sce hooks session-model` / `sce hooks diff-trace` commands. Regenerated outputs. `nix run .#pkl-check-generated` (generated outputs up to date), `nix flake check` (all 7 checks passed: cli-tests, cli-clippy, cli-fmt, config-lib-bun-tests, config-lib-biome-check, config-lib-biome-format, pkl-parity). - [ ] T08: `Sync current-state context for Rust-owned Claude tracing` (status:todo) - Task ID: T08 From e3eac35cdc5f38063763b45bc6a0588955219d44 Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 16:52:04 +0200 Subject: [PATCH 17/18] context: Update current-state docs for Rust-owned Claude tracing migration Remove stale references to the Claude TypeScript agent-trace runtime and outdated "until T05" qualifiers across overview, architecture, glossary, and SCE context files. Reflect the current direct `sce hooks` boundary where Rust handles extraction, validation, and persistence without a TypeScript intermediary. Update the claude-rust-diff-trace plan status to mark T08 and T09 complete and append the final validation report with concrete success-criteria evidence. Co-authored-by: SCE --- context/architecture.md | 2 +- context/glossary.md | 2 +- context/overview.md | 4 +- context/plans/claude-rust-diff-trace.md | 49 ++++++++++++++++++- context/sce/claude-raw-hook-capture.md | 5 +- .../opencode-agent-trace-plugin-runtime.md | 7 +-- 6 files changed, 58 insertions(+), 11 deletions(-) diff --git a/context/architecture.md b/context/architecture.md index 81a08a4a..194193f1 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -37,7 +37,7 @@ Current target renderer helper modules: - `config/pkl/generate.pkl` (single multi-file generation entrypoint) - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) - `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,pkl-parity,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, generated-output parity, and JS validation inventory) -- `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden fixture coverage (Rust-owned since T02; the TypeScript Bun test remains present until T05). +- `config-lib-bun-tests` executes from `config/lib/` while using a repo-shaped copied source subset that also includes `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden fixture coverage (fully Rust-owned; the Claude TypeScript Bun test was removed in T07). The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, generated Claude plugin entrypoints, generated OpenCode package manifests, and generated Claude project settings). diff --git a/context/glossary.md b/context/glossary.md index e54584db..391e01b5 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -13,7 +13,7 @@ - `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split shared `config/lib/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. - `config-lib JS flake checks`: The current shared `config/lib/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs Bun-discovered tests from the copied shared `config/lib/` package source (including bash-policy runtime tests and tracked agent-trace plugin tests), with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`, while `config-lib-biome-check` and `config-lib-biome-format` run Biome lint/check and format verification over the copied shared package source with formatter/linter halves disabled respectively. -- `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden tests (Rust-owned since T02; the TypeScript Bun test remains present until T05). +- `config-lib repo-shaped test source`: Root-flake source-layout contract where `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format` run from `config/lib/` while their copied Nix source preserves repo-relative shared fixtures, currently `cli/src/services/structured_patch/fixtures` for Claude agent-trace golden tests (fully Rust-owned; the Claude TypeScript Bun test was removed in T07). - `config-lib shared package root`: Shared Bun/TypeScript package root at `config/lib/` for repository-owned OpenCode plugin support code. It owns `package.json`, `bun.lock`, and `tsconfig.json`, pins `@opencode-ai/plugin` to `1.15.4`, includes both `agent-trace-plugin/**/*.ts` and `bash-policy-plugin/**/*.ts` in strict TypeScript coverage, and excludes package-local `node_modules/` from both TypeScript and root Biome coverage. - `cli rust overlay toolchain`: Toolchain contract in root `flake.nix` that applies `rust-overlay.overlays.default`, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, uses that toolchain across both Crane package and check derivations, and keeps toolchain selection explicit rather than inheriting nixpkgs defaults. - `cli Crane package pipeline`: Current root-flake packaging path in `flake.nix` where `packages.sce` is built through `craneLib.buildDepsOnly` plus `craneLib.buildPackage` against a filtered repo-root source that preserves the Cargo tree and the embedded config/assets required by `cli/build.rs`. diff --git a/context/overview.md b/context/overview.md index dc6871ff..f5aa155b 100644 --- a/context/overview.md +++ b/context/overview.md @@ -24,14 +24,14 @@ Invalid default-discovered config files now also degrade gracefully at startup: The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots through a dedicated internal `roots` seam, exposes the current persisted-artifact inventory (global config and auth tokens), and also defines named DB paths (auth DB, local DB, Agent Trace DB) plus the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: `SCE_ATTRIBUTION_HOOKS_ENABLED` overrides `policies.attribution_hooks.enabled`, and the gate defaults to disabled. The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. -Generated config now includes repo-local plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config now emits the Bun-run agent-trace runtime at `config/.claude/plugins/sce-agent-trace.ts`; `config/.claude/settings.json` registers `SessionStart`, `UserPromptSubmit`, matched `PostToolUse`, and `Stop` handlers through that runtime, which sends normalized `session-model` payloads to Rust for SessionStart model attribution and forwards supported Claude `PostToolUse` diff traces to `sce hooks diff-trace`. The Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. +Generated config now includes repo-local plugin assets for both profiles: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/` and `config/automated/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config now routes agent-trace events through `.claude/settings.json` command hooks that call `sce hooks` directly: `SessionStart` pipes raw hook event JSON to `sce hooks session-model`, and matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes raw hook event JSON to `sce hooks diff-trace`. Rust handles extraction, validation, and persistence without a TypeScript intermediary; the former `config/.claude/plugins/sce-agent-trace.ts` Bun runtime was removed in T07 of the `claude-rust-diff-trace` plan. The Rust hook continues to validate required fields and persists `model_id`, `tool_name`, and nullable `tool_version` into `diff_traces` through AgentTraceDb. Bash-policy also emits shared runtime logic and preset data under `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. The `doctor` command now exposes explicit inspection mode (`sce doctor`) and repair-intent mode (`sce doctor --fix`) at the CLI/help/schema level while keeping diagnosis mode read-only. It now validates both current global operator health and the current repo/hook-integrity slice: state-root resolution, global config path resolution, global and repo-local `sce/config.json` readability/schema validity, local DB and Agent Trace DB path + health, DB parent-directory readiness, git availability, non-repo vs bare-repo targeting failures, effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`), hooks-directory health, required hook presence/executable permissions/content drift against canonical embedded SCE-managed hook assets, and repo-root OpenCode integration presence across the installed `plugins`, `agents`, `commands`, and `skills` inventories with embedded SHA-256 content verification for OpenCode assets. Text mode now renders the approved human-only layout with ordered `Environment` / `Configuration` / `Repository` / `Git Hooks` / `Integrations` sections, `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens, shared-style green pass plus red fail/miss coloring when color output is enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows that reflect missing vs content-mismatch states; JSON output now reports Agent Trace DB health under `agent_trace_db` (as a row within the Configuration section in text mode). Repo-scoped database reporting is empty by default because no repo-owned SCE database currently exists. Fix mode reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and can also bootstrap missing canonical DB parent directories while preserving manual-only guidance for unsupported issues. Local database bootstrap is now owned by `LocalDbLifecycle::setup` and `AgentTraceDbLifecycle::setup` aggregated by the setup command, while doctor validates both DB paths/health and can bootstrap missing parent directories. Wiring a user-invocable `sce sync` command is deferred to `0.4.0`. The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable toolchain pinned to `1.93.1` (with `rustfmt` and `clippy`), reads package/check version from the repo-root `.version` file, builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline with filtered package sources for the Cargo tree plus required embedded config/assets, and runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed check derivations (`cargoTest`, `cargoClippy`, `cargoFmt`) that reuse the same filtered source/toolchain setup. The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in `cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`, `cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`, and local `cargo install --path cli --locked`. The published crate installs the `sce` binary. The crate also keeps `cargo clippy --manifest-path cli/Cargo.toml` warnings-denied through `cli/Cargo.toml` lint configuration, so an extra `-- -D warnings` flag is redundant. The repository-root flake is now the single Nix entrypoint for both repo tooling and CLI packaging/checks, so root-level `nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus six split JavaScript check derivations: `npm-bun-tests`, `npm-biome-check`, `npm-biome-format`, `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format`, without nested-flake indirection. The config-lib checks now consume `config/lib/` as the shared Bun/TypeScript package root for both `agent-trace-plugin/` and `bash-policy-plugin/`, with dependencies resolved from `config/lib/package.json` and `config/lib/bun.lock`. For Cargo packaging/builds, the crate now compiles against a temporary `cli/assets/generated/` mirror prepared from canonical `config/` outputs during Nix builds and crates.io publish runs rather than from a committed crate-local snapshot. -Config-lib JS flake checks execute from `config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests consume `cli/src/services/structured_patch/fixtures` through their normal repo-relative path (Rust-owned since T02; the TypeScript Bun test remains present until T05). +Config-lib JS flake checks execute from `config/lib/`, but the copied Nix check source is repo-shaped when tests require shared repo fixtures; the current Claude agent-trace golden tests are fully Rust-owned in `cli/src/services/structured_patch/fixtures` (Claude TypeScript plugin test removed in T07). Local developer Nix tuning guidance now lives in `AGENTS.md`, including optional user-level `~/.config/nix/nix.conf` recommendations for `max-jobs` and `cores` plus an explicit system-level-only note for `auto-optimise-store`. The Pkl authoring layer owns generated OpenCode plugin registration for SCE-managed plugins: `config/pkl/base/opencode.pkl` defines the canonical plugin entries, `config/pkl/renderers/common.pkl` re-exports the shared plugin list for renderer use, and generated `config/.opencode/opencode.json` plus `config/automated/.opencode/opencode.json` register `./plugins/sce-bash-policy.ts` and `./plugins/sce-agent-trace.ts` through OpenCode's `plugin` field. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. The current first-wave CLI install/distribution contract is now defined for `sce`: the active implemented channel set is repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the current implementation stage. Nix-managed build/release entrypoints are the source of truth for this rollout, npm consumes Nix-produced release artifacts, and repo-root `.version` is the canonical checked-in release version source that release packaging and downstream Cargo/npm publication must match. The shared release artifact foundation is now implemented through root-flake apps `release-artifacts` and `release-manifest`, which emit canonical `sce-v-.tar.gz` archives, SHA-256 checksum files, merged manifest outputs, and a detached `sce-v-release-manifest.json.sig` produced from a non-repo private signing key; the npm distribution surface is now implemented as a checked-in `npm/` launcher package plus root-flake `release-npm-package`, which packs `sce-v-npm.tgz`, refuses mismatched checked-in package metadata, and installs the native CLI by downloading the release manifest plus detached signature, verifying the manifest with the bundled npm public key, and only then checksum-verifying the matching GitHub release archive at npm `postinstall` time. GitHub Releases are the canonical publication surface for those release artifacts, while crates.io and npm registry publication are separate non-bumping publish stages under the approved release topology. GitHub CLI release automation now lives in dedicated `release-sce*.yml` workflows split by Linux, Linux ARM, and macOS ARM, and `.github/workflows/release-sce.yml` now orchestrates those three reusable platform lanes before assembling the signed release manifest, npm tarball, and GitHub release payload. The orchestrator now tags/releases the checked-in `.version` directly and rejects version mismatches instead of generating a new semver during workflow execution, `.github/workflows/publish-crates.yml` is the dedicated crates.io publish stage triggered from a published GitHub release or manual dispatch with the same `.version`/tag/Cargo parity checks and a clean temporary repo copy for Cargo packaging, and `release-agents.yml` remains Tessl-only. diff --git a/context/plans/claude-rust-diff-trace.md b/context/plans/claude-rust-diff-trace.md index e519bd04..14c5feae 100644 --- a/context/plans/claude-rust-diff-trace.md +++ b/context/plans/claude-rust-diff-trace.md @@ -101,20 +101,65 @@ Planning interpretation: the last nine commits created and then refined a Claude - Verification notes (commands or checks): Run `nix run .#pkl-check-generated`; run the relevant config-lib checks only if package/test manifests changed, otherwise rely on T09 full validation. - Completion evidence (2026-06-10): Deleted `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts` (canonical source), `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.test.ts` (Bun test), `config/.claude/plugins/sce-agent-trace.ts` (generated), and `.claude/plugins/sce-agent-trace.ts` (root copy). Removed Claude plugin source read and output mapping from `config/pkl/generate.pkl`. Updated root `.claude/settings.json` to direct `sce hooks session-model` / `sce hooks diff-trace` commands. Regenerated outputs. `nix run .#pkl-check-generated` (generated outputs up to date), `nix flake check` (all 7 checks passed: cli-tests, cli-clippy, cli-fmt, config-lib-bun-tests, config-lib-biome-check, config-lib-biome-format, pkl-parity). -- [ ] T08: `Sync current-state context for Rust-owned Claude tracing` (status:todo) +- [x] T08: `Sync current-state context for Rust-owned Claude tracing` (status:done) - Task ID: T08 - Goal: Update durable context to describe the new Rust-owned Claude derivation boundary and removal of Claude TypeScript plugin runtime. - Boundaries (in/out of scope): In - focused updates to `context/sce/opencode-agent-trace-plugin-runtime.md`, `context/sce/agent-trace-hooks-command-routing.md`, `context/cli/patch-service.md`, `context/context-map.md`, `context/overview.md`, and glossary/architecture entries if needed. Out - historical narration beyond current-state facts, unrelated context cleanup. - Done when: Context says OpenCode still uses TypeScript normalized diff traces, diff-trace storage uses typed source payloads, Claude settings call `sce hooks` directly, Rust derives Claude structured patches during post-commit processing, and golden tests are Rust-owned. - Verification notes (commands or checks): Review context references for stale `.claude/plugins/sce-agent-trace.ts`, Claude TypeScript golden test, and shared TypeScript-runtime-to-Rust boundary claims. + - Completion evidence (2026-06-10): Updated `context/sce/opencode-agent-trace-plugin-runtime.md` (removed stale Claude TypeScript source listing, updated golden tests section), `context/overview.md` (replaced Claude Bun-runtime paragraph with direct-command-hook description, fixed stale "until T05" qualifier), `context/architecture.md` (fixed stale "until T05" qualifier), `context/glossary.md` (fixed stale "until T05" qualifier), `context/sce/claude-raw-hook-capture.md` (updated "Current state" to reflect direct `sce hooks` boundary and former TypeScript runtime removal). Confirmed zero remaining stale references via targeted search. `nix run .#pkl-check-generated` passed. -- [ ] T09: `Validate and clean up Claude Rust diff-trace migration` (status:todo) +- [x] T09: `Validate and clean up Claude Rust diff-trace migration` (status:done) - Task ID: T09 - Goal: Run final validation, remove temporary scaffolding, and record plan completion evidence. - Boundaries (in/out of scope): In - full repo validation, generated-output parity, checking for stale Claude TypeScript references, updating this plan with validation evidence. Out - new feature work or unrelated refactors discovered during validation. - Done when: `nix run .#pkl-check-generated` and `nix flake check` pass; no stale Claude plugin TypeScript files/references remain except intentional historical references; plan status/evidence is updated. - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`; targeted search for `.claude/plugins/sce-agent-trace.ts`, `deriveClaudeDiffTracePayload`, and Claude TypeScript golden-test references; targeted search/review that Claude structured payload rows are not rendered into patchsets before persistence. + - Completion evidence (2026-06-10): + - **`nix run .#pkl-check-generated`**: passed (generated outputs up to date) + - **`nix flake check`**: all 4 checks passed (cli-tests, cli-clippy, cli-fmt, pkl-parity) + - **Stale file check**: `config/.claude/plugins/` and `.claude/plugins/` directories do not exist; only `opencode-sce-agent-trace-plugin.ts` remains in `config/lib/agent-trace-plugin/` + - **Settings.json**: `config/.claude/settings.json` calls `sce hooks session-model` and `sce hooks diff-trace` directly (no agent-trace plugin or Bun invocations) + - **Structured payload contract (code review confirmed)**: + - Claude `PostToolUse` payloads stored as raw JSON with `payload_type="structured"` at `diff-trace` intake (`cli/src/services/hooks/mod.rs` line 352: `diff: stdin_payload.to_string()`) + - Post-commit read-time dispatch: `payload_type="structured"` rows parse stored JSON through `derive_claude_structured_patch` (`cli/src/services/agent_trace_db/mod.rs` lines 437-443) + - **Temporary scaffolding**: none found; `PAYLOAD_TYPE_STRUCTURED` is properly active (no `#[allow(dead_code)]`); `structured_patch.rs` has no file-level `#![allow(dead_code)]`; no plan-specific TODOs in Rust code ## Open questions - None blocking. User clarified that Claude derivation should happen fully in Rust, Claude TypeScript should be removed, OpenCode TypeScript should remain, generated Claude settings should call `sce hooks` directly, and AgentTraceDb should persist generic typed source payloads so Claude structured payloads are converted to `ParsedPatch` during post-commit processing rather than insert-time patchset rendering. + +--- + +## Validation Report + +### Commands run + +| Command | Exit code | Result | +|---------|-----------|--------| +| `nix run .#pkl-check-generated` | 0 | Generated outputs are up to date | +| `nix flake check` | 0 | All 4 checks passed: cli-tests, cli-clippy, cli-fmt, pkl-parity | + +### Temporary scaffolding + +None found. `PAYLOAD_TYPE_STRUCTURED` is properly active (no `#[allow(dead_code)]`); `structured_patch.rs` has no file-level `#![allow(dead_code)]`; no plan-specific TODOs in Rust source. + +### Success-criteria verification + +- [x] **Generated Claude settings call `sce hooks` directly**: `config/.claude/settings.json` uses `"sce"` command with `"hooks" "session-model"` and `"hooks" "diff-trace"` args; no Bun or `.claude/plugins/sce-agent-trace.ts` references. File verified on disk. + +- [x] **AgentTraceDb typed payload storage**: `PAYLOAD_TYPE_PATCH` (`"patch"`) and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) constants at `cli/src/services/agent_trace_db/mod.rs:73-74`; migration `009_add_diff_traces_payload_type.sql` added `payload_type TEXT NOT NULL DEFAULT 'patch'` column. Code review confirmed. + +- [x] **Claude structured payloads stored as raw JSON, derived at post-commit read time**: Intake path (`cli/src/services/hooks/mod.rs:352`) stores `stdin_payload.to_string()` with `payload_type="structured"`. Post-commit read path (`cli/src/services/agent_trace_db/mod.rs:437-443`) dispatches `"structured"` rows through `derive_claude_structured_patch` at read time. Code review confirmed. + +- [x] **OpenCode normalized payloads unchanged**: Continue as `payload_type="patch"` through existing flat-payload validation and `parse_patch` processing. Code review confirmed. + +- [x] **Claude TypeScript removed**: `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts` (canonical source) deleted; `config/.claude/plugins/` directory does not exist; `.claude/plugins/` directory does not exist; only `opencode-sce-agent-trace-plugin.ts` remains. File system verified. + +- [x] **Golden fixture coverage lives in Rust**: `cli/src/services/structured_patch/tests.rs` (`claude_derivation_golden_tests`) validates all eight `diff_creation/` scenarios against `derive_claude_structured_patch`. Context docs confirmed. + +- [x] **Generated output parity and full repo validation pass**: `nix run .#pkl-check-generated` exit 0; `nix flake check` exit 0 (all 4 checks green). + +### Residual risks + +None identified. All plan success criteria met with concrete evidence. diff --git a/context/sce/claude-raw-hook-capture.md b/context/sce/claude-raw-hook-capture.md index ae35c00f..5a63a0e6 100644 --- a/context/sce/claude-raw-hook-capture.md +++ b/context/sce/claude-raw-hook-capture.md @@ -23,8 +23,9 @@ The generated Claude TypeScript runtime at `config/.claude/plugins/sce-agent-tra ## Current state -- The Claude TypeScript runtime sends only normalized `session-model` (for `SessionStart` model attribution) and `diff-trace` (for supported `PostToolUse` file-change payloads) payloads to Rust. -- Rust owns normalized persistence: `session-model` upserts into `session_models`, `diff-trace` writes parsed-payload artifacts under `context/tmp/*-diff-trace.json` and inserts into `diff_traces`. +- Claude settings call `sce hooks` directly via generated `.claude/settings.json` command hooks: `SessionStart` pipes raw hook event JSON to `sce hooks session-model`, matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes raw hook event JSON to `sce hooks diff-trace`. Rust handles extraction, validation, and persistence without a TypeScript intermediary. +- The former Claude TypeScript runtime at `config/.claude/plugins/sce-agent-trace.ts` was removed in T07 of the `claude-rust-diff-trace` plan. +- Rust owns normalized persistence: `session-model` upserts into `session_models`, `diff-trace` inserts into `diff_traces` with `payload_type` classification (`"patch"` for OpenCode, `"structured"` for Claude). - Claude `diff-trace` `model_id` is resolved from `session_models` at persistence time; OpenCode sends `model_id` directly. - No raw Claude hook payload artifacts are written by TypeScript or Rust. diff --git a/context/sce/opencode-agent-trace-plugin-runtime.md b/context/sce/opencode-agent-trace-plugin-runtime.md index 840ced18..e00fad81 100644 --- a/context/sce/opencode-agent-trace-plugin-runtime.md +++ b/context/sce/opencode-agent-trace-plugin-runtime.md @@ -1,9 +1,10 @@ # OpenCode agent-trace plugin runtime -Current TypeScript runtime sources: +Current TypeScript runtime source: - `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` -- `config/lib/agent-trace-plugin/claude-sce-agent-trace-plugin.ts` + +The Claude TypeScript agent-trace runtime was removed in T07 of the `claude-rust-diff-trace` plan. Claude now routes through generated `.claude/settings.json` command hooks that call `sce hooks` directly with raw hook event JSON on STDIN; Rust handles extraction, validation, and persistence without a TypeScript intermediary. ## Event capture baseline @@ -52,4 +53,4 @@ Otherwise, the helper returns `undefined`. - Rust golden tests in `cli/src/services/structured_patch/tests.rs` (`claude_derivation_golden_tests`) own the Claude derivation fixture coverage. - The test dynamically discovers the checked-in `cli/src/services/structured_patch/fixtures/` scenario directories, validates the expected eight-scenario set, then loads each `claude-post-tool-use.json` plus `expected.patch` pair. - Each scenario calls `derive_claude_structured_patch(...)` with fixed time/tool-version inputs and asserts derived status, session ID, time, `tool_name="claude"`, tool version, exact golden diff parsed via `parse_patch`, and no emitted `model_id`. -- The TypeScript `deriveClaudeDiffTracePayload(...)` seam and its Bun test remain present until T05 removes the Claude TypeScript plugin source. +- The former TypeScript `deriveClaudeDiffTracePayload(...)` seam and its Bun test were removed in T07 when the Claude TypeScript plugin source was deleted. From ddee01824fc2d263b78ef4b8956e5f676f36ef8a Mon Sep 17 00:00:00 2001 From: David Abram Date: Wed, 10 Jun 2026 17:04:40 +0200 Subject: [PATCH 18/18] agents: Simplify Claude hook commands and reorder OpenCode plugin imports Update Claude Code settings to use single command strings instead of separate command/args arrays for hook invocations. Co-authored-by: SCE --- .claude/settings.json | 12 ++---------- .opencode/plugins/sce-agent-trace.ts | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 65b949d2..ee1ca3f1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,11 +5,7 @@ "hooks": [ { "type": "command", - "command": "sce", - "args": [ - "hooks", - "session-model" - ] + "command": "sce hooks session-model" } ] } @@ -20,11 +16,7 @@ "hooks": [ { "type": "command", - "command": "sce", - "args": [ - "hooks", - "diff-trace" - ] + "command": "sce hooks diff-trace" } ] } diff --git a/.opencode/plugins/sce-agent-trace.ts b/.opencode/plugins/sce-agent-trace.ts index 33e6ad7a..8546182d 100644 --- a/.opencode/plugins/sce-agent-trace.ts +++ b/.opencode/plugins/sce-agent-trace.ts @@ -1,5 +1,5 @@ -import type { Hooks, Plugin } from "@opencode-ai/plugin"; import { spawn } from "node:child_process"; +import type { Hooks, Plugin } from "@opencode-ai/plugin"; type OpenCodeEvent = Parameters>[0]["event"];