From 95dcb73e701d6f43fa7c2d053a58af649e748988 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:40:14 +0300 Subject: [PATCH 01/12] fix: exclude dev-only Reins skill from git tracking .agents/ and skills-lock.json are dev tooling installed via `npx skills add`. They were being shipped to users via `npx skills add selftune-dev/selftune`, causing Reins to appear as part of the selftune skill. Now gitignored so only the intended selftune skill in skill/ is visible to consumers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .agents/skills/reins/HarnessMethodology.md | 159 --------------------- .agents/skills/reins/SKILL.md | 125 ---------------- .agents/skills/reins/Workflows/Audit.md | 143 ------------------ .agents/skills/reins/Workflows/Doctor.md | 84 ----------- .agents/skills/reins/Workflows/Evolve.md | 80 ----------- .agents/skills/reins/Workflows/Scaffold.md | 66 --------- .gitignore | 4 + skills-lock.json | 10 -- 8 files changed, 4 insertions(+), 667 deletions(-) delete mode 100644 .agents/skills/reins/HarnessMethodology.md delete mode 100644 .agents/skills/reins/SKILL.md delete mode 100644 .agents/skills/reins/Workflows/Audit.md delete mode 100644 .agents/skills/reins/Workflows/Doctor.md delete mode 100644 .agents/skills/reins/Workflows/Evolve.md delete mode 100644 .agents/skills/reins/Workflows/Scaffold.md delete mode 100644 skills-lock.json diff --git a/.agents/skills/reins/HarnessMethodology.md b/.agents/skills/reins/HarnessMethodology.md deleted file mode 100644 index f50a0cae..00000000 --- a/.agents/skills/reins/HarnessMethodology.md +++ /dev/null @@ -1,159 +0,0 @@ -# Harness Engineering Methodology Reference - -> Source: OpenAI's "Harness Engineering" (Feb 2026, Ryan Lopopolo) - -## Philosophy - -Build and ship software with **zero manually-written code**. Humans design environments, specify intent, and build feedback loops. Agents write all code, tests, CI, docs, and tooling. - -## Reins CLI Mapping - -Reins turns the methodology into four operational commands: -- `reins init` — scaffold repository knowledge and governance artifacts -- `reins audit` — score maturity across six dimensions (0-18) -- `reins doctor` — produce actionable pass/fail/warn health checks -- `reins evolve` — generate step-by-step upgrades to the next level - -## The Five Pillars - -### 1. Repository as System of Record - -All knowledge must be versioned, in-repo, and agent-discoverable: - -``` -AGENTS.md # ~100 lines, map to deeper docs -ARCHITECTURE.md # Domain map, package layering, dependency rules -docs/ - design-docs/ # Indexed design decisions with verification status - index.md - core-beliefs.md - exec-plans/ # First-class execution plans - active/ - completed/ - tech-debt-tracker.md - generated/ # Auto-generated docs (DB schema, API specs) - product-specs/ # Product requirements and specs - index.md - references/ # External reference docs (LLM-friendly) -``` - -**Rules:** -- AGENTS.md is a map, not a manual (~100 lines) -- No knowledge lives in Slack, Google Docs, or human heads -- A "doc-gardening" agent scans for stale docs on a cadence -- A verification agent checks freshness and cross-links - -### 2. Layered Domain Architecture - -Each business domain follows a strict layer ordering: - -``` -Utils - | - v -Business Domain - +-- Types --> Config --> Repo --> Service --> Runtime --> UI - | - +-- Providers (cross-cutting: auth, connectors, telemetry, feature flags) - | - v - App Wiring + UI -``` - -**Rules:** -- Dependencies only flow "forward" (left to right) -- Cross-cutting concerns enter ONLY through Providers -- Enforce mechanically with custom linters and structural tests -- Violations fail CI, not code review - -### 3. Agent Legibility - -Optimize everything for agent understanding: - -- Boot the app per git worktree (one instance per change) -- Wire Chrome DevTools Protocol into agent runtime (DOM snapshots, screenshots, navigation) -- Expose logs/metrics/traces via local observability stack (LogQL, PromQL, TraceQL) -- Ephemeral observability per worktree, torn down after task completion -- For CLI-first repositories, prioritize diagnosability surfaces (structured command output, doctor/help commands, deterministic error metadata) when full service observability is not relevant -- Prefer "boring" technology — composable, stable APIs, well-represented in training data -- Reimplement simple utilities rather than pulling opaque dependencies - -### 4. Golden Principles (Mechanical Taste) - -Opinionated rules that encode human taste mechanically: - -- Prefer shared utility packages over hand-rolled helpers -- Validate data at boundaries, never probe shapes YOLO-style -- Use typed SDKs wherever possible -- Formatting and structural rules enforced in CI -- Rules checked and enforced by agents themselves -- Capture review feedback as documentation or tooling updates - -### 5. Garbage Collection (Continuous Cleanup) - -- Background agents scan for deviations on a recurring cadence -- Quality grades track each domain and architectural layer -- Targeted refactoring PRs auto-generated and auto-merged -- Technical debt paid continuously in small increments -- Stale documentation detected and updated automatically - -## Agent Autonomy Levels - -### Level 1: Prompted Execution -Agent receives prompt, writes code, opens PR. Human reviews and merges. - -### Level 2: Agent Review Loop -Agent writes code, runs self-review, requests agent reviews, iterates until satisfied. Human spot-checks. - -### Level 3: Full Autonomy -Agent validates codebase, reproduces bug, implements fix, validates fix, opens PR, responds to feedback, remediates failures, merges. Escalates only when judgment needed. - -## Merge Philosophy - -- Minimal blocking merge gates -- Short-lived PRs -- Test flakes addressed with follow-up runs, not blocking -- Corrections are cheap; waiting is expensive - -## Patterns from Production - -Real-world signals observed in production harness-engineered codebases that indicate mature practices: - -### 1. Risk Policy as Code -`risk-policy.json` defines risk tiers, watch paths, and escalation rules. Enables automated decisions about review depth and deployment gates. - -### 2. Verification Headers -`` headers in documentation files. Enables automated freshness tracking and doc-gardening. - -### 3. Doc-Gardener Automation -Freshness scripts in CI that scan for stale docs, missing verification headers, and orphaned references. Runs on a cadence, not just at PR time. - -### 4. Hierarchical AGENTS.md -Per-package AGENTS.md files in monorepos. Each package has its own discoverable context, avoiding a single monolithic file that rots instantly. - -### 5. Design Decision Records with Consequences -Design docs that track not just the decision but also the consequences, trade-offs, and verification status. Indexed in `design-docs/index.md`. - -### 6. Execution Plan Culture -Workstream tracking with versioned execution plans in-repo. Active plans, completed plans, and tech debt tracked as first-class artifacts. - -### 7. Quality Grades per Domain -A/B/C/D grades assigned per domain and architectural layer. Provides clear visibility into where quality is strong and where cleanup is needed. - -### 8. Lint Baseline/Ratchet Mechanism -Structural lint rules that only tighten over time. New violations fail CI, but existing violations are baselined and reduced incrementally. - -### 9. Product Specs as Harness Artifacts -Product requirements versioned in-repo alongside design docs and execution plans. Agents can reference specs directly rather than relying on external tools. - -### 10. i18n as Schema Constraint -Internationalization treated as a structural schema constraint rather than an afterthought. Enforced at the type level, not bolted on later. - -## Anti-Patterns - -- One giant AGENTS.md (context starvation, instant rot) -- Knowledge in external tools (Slack, Google Docs, wikis) -- Human code fixes (removes incentive for self-correction) -- Manual code review as primary quality gate -- Opaque dependencies agents can't reason about -- Letting tech debt compound without garbage collection diff --git a/.agents/skills/reins/SKILL.md b/.agents/skills/reins/SKILL.md deleted file mode 100644 index 7e004e54..00000000 --- a/.agents/skills/reins/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: Reins -description: Reins CLI skill for scaffold/audit/doctor/evolve workflows. Use when setting up or evaluating harness-engineering repo readiness and maturity with Reins commands. ---- - -# Reins - -Use the Reins CLI to operationalize harness engineering in any repository. - -## Execution Model (Critical) - -1. The CLI is the execution engine and scoring source of truth. -2. This skill is the control plane for agent behavior (routing, command order, JSON parsing discipline). -3. Humans steer goals and tradeoffs; agents execute the loop. - -Do not re-implement CLI logic in skill instructions. Always run commands and parse JSON outputs. - -## Use When - -Use this skill when the user asks to: -- Scaffold repository readiness artifacts (`AGENTS.md`, `ARCHITECTURE.md`, `docs/`, `risk-policy.json`) -- Audit or score agent-readiness/maturity (0-18, maturity levels, weakest dimensions) -- Diagnose readiness gaps with `doctor` (pass/fail/warn health checks with prescriptive fixes) -- Evolve the repository to the next Reins maturity level -- Improve docs-drift/policy-as-code enforcement tied to Reins outputs -- Understand harness engineering methodology or maturity levels - -## Don't Use When - -Do not use this skill for: -- Generic code implementation/debugging unrelated to Reins workflows -- General-purpose lint/test/security checks that do not request Reins scoring or scaffolding -- Product/domain feature design that does not involve harness-engineering structure -- Questions about installing random third-party skills (use skill discovery/installer flows instead) - -## Command Execution Policy - -Use this order when running commands: - -1. In user repositories, check if installed skills are stale: -`npx skills check` -If updates are available, refresh before running workflow commands: -`npx skills update` -2. If working inside the Reins repository itself: -`cd cli/reins && bun src/index.ts ../..` -3. Otherwise (or if local source is unavailable): -`npx reins-cli@latest ` - -All Reins commands output deterministic JSON. **Always parse JSON output** — never text-match against findings strings. - -## Quick Reference - -### CLI Commands - -```bash -# Scaffold harness engineering structure -reins init [--name ] [--force] [--pack ] - -# Score maturity across 6 dimensions (0-18) -reins audit - -# Health check with prescriptive fixes -reins doctor - -# Roadmap to next maturity level -reins evolve [--apply] - -# Show usage -reins help -``` - -### Maturity Levels - -| Score | Level | Description | -|-------|-------|-------------| -| 0-4 | **L0: Manual** | Traditional engineering, no agent infrastructure | -| 5-8 | **L1: Assisted** | Agents help, but humans still write code | -| 9-13 | **L2: Steered** | Humans steer, agents execute most code | -| 14-16 | **L3: Autonomous** | Agents handle full lifecycle with human oversight | -| 17-18 | **L4: Self-Correcting** | Agents maintain, clean, and evolve the system | - -## Core Reins Principles - -1. **Repository is the system of record** -- Knowledge stays in versioned files. -2. **Humans steer, agents execute** -- Prompt-first workflows over manual edits where possible. -3. **Mechanical enforcement over intent-only docs** -- CI and policy-as-code back every rule. -4. **Progressive disclosure** -- AGENTS.md is the map, deep docs hold details. -5. **Continuous cleanup** -- Track debt, docs drift, and stale patterns as first-class work. - -## Workflow Routing - -| Trigger | Workflow | File | -|---------|----------|------| -| scaffold, init, setup, bootstrap | Scaffold | Workflows/Scaffold.md | -| audit, score, assess, readiness | Audit | Workflows/Audit.md | -| doctor, health check, diagnose, gaps | Doctor | Workflows/Doctor.md | -| evolve, improve, mature, level up | Evolve | Workflows/Evolve.md | - -## Resource Index - -| Resource | Purpose | -|----------|---------| -| `SKILL.md` | Skill routing, triggers, quick reference | -| `HarnessMethodology.md` | Full methodology reference (5 pillars, 10 production patterns) | -| `Workflows/Scaffold.md` | Scaffold workflow with step-by-step guide | -| `Workflows/Audit.md` | Audit workflow with dimension details and output format | -| `Workflows/Doctor.md` | Doctor workflow with health checks and fix guidance | -| `Workflows/Evolve.md` | Evolve workflow with level-up paths | - -## Examples - -- "Scaffold this repo for Reins" -- "Audit this project with Reins and summarize the weakest dimensions" -- "Run doctor on this repo and fix everything that fails" -- "Evolve this repo to the next Reins maturity level" -- "What's the harness engineering maturity of this project?" - -## Negative Examples - -These should not trigger Reins: -- "Fix this React hydration bug" -- "Add OAuth login to the API" -- "Run normal project lint and unit tests" - -Route to general coding workflows unless the user explicitly asks for Reins scaffolding, audit, doctor, or evolve operations. diff --git a/.agents/skills/reins/Workflows/Audit.md b/.agents/skills/reins/Workflows/Audit.md deleted file mode 100644 index 2a2115cd..00000000 --- a/.agents/skills/reins/Workflows/Audit.md +++ /dev/null @@ -1,143 +0,0 @@ -# Reins Audit Workflow - -Score an existing project against harness engineering principles. Produces a structured assessment with actionable recommendations. - -## Default Command - -Run the CLI first: -- Local source: `cd cli/reins && bun src/index.ts audit ` -- Package mode: `npx reins-cli@latest audit ` - -For remediation detail, pair with doctor: -- `reins doctor ` - -## Output Format - -```json -{ - "project": "project-name", - "timestamp": "2026-02-23T12:39:57.977Z", - "scores": { - "repository_knowledge": { "score": 3, "max": 3, "findings": ["AGENTS.md exists (56 lines)", "..."] }, - "architecture_enforcement": { "score": 3, "max": 3, "findings": ["..."] }, - "agent_legibility": { "score": 3, "max": 3, "findings": ["..."] }, - "golden_principles": { "score": 3, "max": 3, "findings": ["..."] }, - "agent_workflow": { "score": 3, "max": 3, "findings": ["..."] }, - "garbage_collection": { "score": 3, "max": 3, "findings": ["..."] } - }, - "total_score": 18, - "max_score": 18, - "maturity_level": "L4: Self-Correcting", - "recommendations": ["Project is well-structured. Consider evolving to next maturity level."] -} -``` - -## Parsing Audit Output - -### Get Total Score and Level - -```bash -result=$(cd cli/reins && bun src/index.ts audit ) -# Parse: .total_score (integer 0-18) -# Parse: .maturity_level (string like "L4: Self-Correcting") -``` - -### Identify Weakest Dimensions - -```bash -# Find dimensions scoring below max -# Parse: .scores | to_entries[] | select(.value.score < .value.max) -``` - -### Check Specific Dimension - -```bash -# Parse: .scores.repository_knowledge.score === 3 -# Parse: .scores.architecture_enforcement.findings (array of evidence strings) -``` - -## Audit Dimensions - -Score each dimension 0-3: -- **0** = Not present -- **1** = Minimal/ad-hoc -- **2** = Structured but incomplete -- **3** = Fully implemented and enforced - -### 1. Repository Knowledge (0-3) - -| Check | Points | -|-------|--------| -| AGENTS.md exists and under 150 lines (hierarchical: per-package in monorepos) | +1 | -| docs/ directory with indexed design docs (counts decisions in design-docs/index.md) | +1 | -| Verification headers in docs (``) and execution plans versioned in-repo | +1 | - -**Bonus findings:** Hierarchical AGENTS.md detected, verification header count, design decision count. - -### 2. Architecture Enforcement (0-3) - -| Check | Points | -|-------|--------| -| ARCHITECTURE.md with dependency direction rules defined | +1 | -| Linter enforcement depth (structural lint scripts, architectural rules in config) | +1 | -| Enforcement evidence: 2+ signals from (risk-policy.json, CI with lint/test, structural lint scripts, golden principles) | +1 | - -**Bonus findings:** Linter depth details, enforcement signal count. - -### 3. Agent Legibility (0-3) - -| Check | Points | -|-------|--------| -| App bootable per worktree (monorepo-aware: detects workspace packages, checks per-workspace bootability) | +1 | -| Observability accessible to agents (services: Sentry/Vercel/Netlify/Docker; CLIs: diagnosability signals like doctor/help commands) | +1 | -| Boring tech stack, minimal opaque dependencies (monorepo-aware: per-workspace average, threshold <20 single or <30 avg) | +1 | - -**Bonus findings:** Monorepo workspace count, dependency count/average, diagnosability signals. - -### 4. Golden Principles (0-3) - -| Check | Points | -|-------|--------| -| Documented mechanical taste rules (counts principles, detects anti-patterns section) | +1 | -| Rules enforced in CI with depth (counts distinct enforcement steps in CI workflows) | +1 | -| Recurring cleanup/refactoring process (tech debt tracker) | +1 | - -**Bonus findings:** Principle count, anti-patterns detected, CI gate count. - -### 5. Agent Workflow (0-3) - -| Check | Points | -|-------|--------| -| Agent config present (CLAUDE.md, conductor.json, .cursor, AGENTS.md) | +1 | -| Workflow signals (risk-policy.json, PR template, issue templates) | +1 | -| CI quality: 2+ distinct enforcement steps in workflows | +1 | - -**Note:** `actions/checkout` does NOT count as an enforcement gate. - -### 6. Garbage Collection (0-3) - -| Check | Points | -|-------|--------| -| Doc-gardener scripts or freshness automation (active GC detection) | +1 | -| 3+ files with verification headers, or doc-gardener script present | +1 | -| Docs-drift enforcement: risk-policy.json with docsDriftRules, or quality grades in architecture | +1 | - -## Maturity Levels - -| Score | Level | Description | -|-------|-------|-------------| -| 0-4 | **L0: Manual** | Traditional engineering, no agent infrastructure | -| 5-8 | **L1: Assisted** | Agents help, but humans still write code | -| 9-13 | **L2: Steered** | Humans steer, agents execute most code | -| 14-16 | **L3: Autonomous** | Agents handle full lifecycle with human oversight | -| 17-18 | **L4: Self-Correcting** | Agents maintain, clean, and evolve the system | - -## Steps - -1. Run `reins audit ` and capture JSON output -2. Parse `.total_score` and `.maturity_level` for summary -3. Identify weakest dimensions: any `.scores.*.score < 3` -4. Read `.scores.*.findings` arrays for evidence of what was detected -5. Present top 3 actionable recommendations from `.recommendations` -6. If remediation needed, run `reins doctor ` for prescriptive fixes -7. After making changes, re-audit to verify score improvement diff --git a/.agents/skills/reins/Workflows/Doctor.md b/.agents/skills/reins/Workflows/Doctor.md deleted file mode 100644 index 1058bbe8..00000000 --- a/.agents/skills/reins/Workflows/Doctor.md +++ /dev/null @@ -1,84 +0,0 @@ -# Reins Doctor Workflow - -Diagnose readiness gaps with pass/fail/warn checks and prescriptive fixes. - -## Default Command - -Run the CLI first: -- Local source: `cd cli/reins && bun src/index.ts doctor ` -- Package mode: `npx reins-cli@latest doctor ` - -For scoring context, pair with audit: -- `reins audit ` - -## Audit vs Doctor - -| Tool | Purpose | Output | -|------|---------|--------| -| **audit** | Quantitative maturity scoring (0-18) | Scores, findings, maturity level | -| **doctor** | Actionable health checks | check/status/fix entries + summary | - -Use **doctor** for remediation details. Use **audit** for maturity scoring. - -## Output Format - -Doctor output fields are deterministic JSON: - -```json -{ - "command": "doctor", - "project": "project-name", - "target": "/abs/path/to/project", - "summary": { - "passed": 8, - "failed": 2, - "warnings": 3, - "total": 13 - }, - "checks": [ - { - "check": "AGENTS.md exists and concise", - "status": "pass", - "fix": "" - }, - { - "check": "ARCHITECTURE.md missing", - "status": "fail", - "fix": "Run 'reins init .' to create ARCHITECTURE.md" - } - ] -} -``` - -## Checks Covered - -Doctor checks include: -- Repository map and architecture presence (`AGENTS.md`, `ARCHITECTURE.md`) -- Required docs (`docs/design-docs/index.md`, `docs/design-docs/core-beliefs.md`, `docs/product-specs/index.md`, `docs/exec-plans/tech-debt-tracker.md`, `docs/golden-principles.md`) -- Linter and CI signals -- Risk policy (`risk-policy.json`) -- Verification headers in docs -- Optional monorepo/structure checks (hierarchical AGENTS, structural lint scripts) - -Notes: -- Check count is dynamic by repository shape. -- Some checks are advisory warnings (not hard failures). - -## Parsing Doctor Output - -```bash -result=$(cd cli/reins && bun src/index.ts doctor ) -# Parse hard failures: -# .summary.failed - -# Parse fixes to apply: -# .checks[] | select(.status == "fail" or .status == "warn") | {check, fix} -``` - -## Steps - -1. Run `reins doctor ` and capture JSON. -2. Parse `.summary` and failed/warn checks. -3. Execute `.fix` instructions for each failing check. -4. Re-run doctor until failures are resolved. -5. Run `reins audit ` to verify score impact. diff --git a/.agents/skills/reins/Workflows/Evolve.md b/.agents/skills/reins/Workflows/Evolve.md deleted file mode 100644 index ed0e7200..00000000 --- a/.agents/skills/reins/Workflows/Evolve.md +++ /dev/null @@ -1,80 +0,0 @@ -# Reins Evolve Workflow - -Upgrade a project to the next Reins maturity level. - -## Default Command - -Run: -- Local source: `cd cli/reins && bun src/index.ts evolve ` -- Package mode: `npx reins-cli@latest evolve ` - -Optional flag: -- `--apply` (limited auto-apply support) - -## Prerequisite - -Run audit first (or let evolve run it internally) to determine current level. - -## Output Format - -```json -{ - "command": "evolve", - "project": "project-name", - "current_level": "L1: Assisted", - "current_score": 8, - "next_level": "L2: Steered", - "goal": "Shift from human-writes-code to human-steers-agent", - "steps": [ - { - "step": 1, - "action": "Write golden principles", - "description": "Mechanical taste rules in docs/golden-principles.md, enforced in CI — not just documented.", - "automated": true - } - ], - "success_criteria": "Most new code is written by agents, not humans.", - "weakest_dimensions": [ - { - "dimension": "architecture_enforcement", - "score": 1, - "max": 3, - "findings": ["..."] - } - ], - "applied": [], - "recommendations": ["..."] -} -``` - -## Parsing Evolve Output - -```bash -result=$(cd cli/reins && bun src/index.ts evolve ) -# Parse: .current_level, .next_level -# Parse: .steps[] -# Parse: .weakest_dimensions[] -``` - -## About `--apply` - -Current behavior is intentionally narrow: -- It can run `reins init` scaffolding when missing core structure is detected. -- It does not automatically execute all non-trivial/manual evolution steps. - -Treat `--apply` as scaffold assist, not full autonomous evolution. - -## Evolution Paths - -- **L0 -> L1**: establish baseline repo map/docs/architecture and first agent loop. -- **L1 -> L2**: enforce golden principles and shift to prompt-first steering. -- **L2 -> L3**: add policy-as-code, stronger enforcement, and autonomous delivery loops. -- **L3 -> L4**: add active drift detection, quality grading, and continuous cleanup. - -## Steps - -1. Run `reins evolve `. -2. Review `.steps` and split into automated vs manual. -3. Execute the path with agents. -4. Re-run `reins audit `. -5. Confirm maturity/score improvement and record outcomes in `docs/exec-plans/completed/`. diff --git a/.agents/skills/reins/Workflows/Scaffold.md b/.agents/skills/reins/Workflows/Scaffold.md deleted file mode 100644 index 6e0b05a2..00000000 --- a/.agents/skills/reins/Workflows/Scaffold.md +++ /dev/null @@ -1,66 +0,0 @@ -# Reins Scaffold Workflow - -Set up a repository with Reins harness-engineering structure. - -## Default Command - -Use Reins before manual scaffolding: -- Local source: `cd cli/reins && bun src/index.ts init ` -- Package mode: `npx reins-cli@latest init ` - -## Output Format - -```json -{ - "command": "init", - "project": "project-name", - "target": "/abs/path/to/project", - "requested_automation_pack": null, - "automation_pack": null, - "automation_pack_reason": "No optional automation pack selected.", - "created": [ - "docs/design-docs/", - "docs/exec-plans/active/", - "docs/exec-plans/completed/", - "docs/generated/", - "docs/product-specs/", - "docs/references/", - "AGENTS.md", - "ARCHITECTURE.md", - "risk-policy.json", - "docs/golden-principles.md", - "docs/design-docs/index.md", - "docs/design-docs/core-beliefs.md", - "docs/product-specs/index.md", - "docs/exec-plans/tech-debt-tracker.md" - ], - "next_steps": [ - "Edit AGENTS.md — fill in the project description", - "Edit ARCHITECTURE.md — define your business domains", - "Review risk-policy.json — set tiers and docs drift rules for your repo", - "Edit docs/golden-principles.md — customize rules for your project", - "Run 'reins audit .' to see your starting score" - ] -} -``` - -Notes: -- `created` includes both directories (with trailing `/`) and files. -- Existing scaffolding is refused unless `--force` is used. -- `automation_pack` is `null` by default, `"agent-factory"` when explicitly requested, or selected adaptively when `--pack auto` is used. - -## Flags - -| Flag | Purpose | Example | -|------|---------|---------| -| `--name ` | Set project name (default: directory name) | `reins init . --name MyProject` | -| `--force` | Overwrite existing files | `reins init . --force` | -| `--pack ` | Optional automation templates (`auto`, `agent-factory`) | `reins init . --pack auto` | - -## Steps - -1. Run `reins init `. -2. Parse `created` to verify scaffold results. -3. Customize generated `AGENTS.md`, `ARCHITECTURE.md`, and `docs/golden-principles.md`. -4. Tune `risk-policy.json` for real watch paths/docs drift rules. -5. Run `reins audit ` and `reins doctor `. diff --git a/.gitignore b/.gitignore index c8e6f36e..aa559ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ docs/strategy/ Plans/ MEMORY/ +# Installed skills (dev tooling, not part of the selftune skill) +.agents/ +skills-lock.json + # Secrets .env.local .env diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 1e385003..00000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "Reins": { - "source": "welldundun/reins", - "sourceType": "github", - "computedHash": "53416e808d3174656a0448505e6f4178d693a0cd342f839d0f3a6f8cdbe29e8a" - } - } -} From 4db8bf7d8d4c5efb5c94211c40e3e58790da737c Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:39:57 +0300 Subject: [PATCH 02/12] refactor: remove legacy --alpha-key flag, use device-code flow only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the manual API key provisioning path (--alpha-key flag) from the CLI. Alpha enrollment now exclusively uses the device-code flow: browser opens automatically, user approves, credentials are provisioned and stored without any manual copy-paste. Also removes zod schemas from telemetry-contract — the cloud repo (gwangju-v1) already owns its own validation schemas in @selftune/shared. The OSS CLI only needs types and hand-written validators (zero deps). - Remove --alpha-key flag from init CLI and InitOptions interface - Remove direct-key code branch from runInit() - Update agent-guidance to reference device-code flow - Update flush.ts error messages - Update Initialize.md, Doctor.md, SKILL.md, alpha-remote-data-contract.md - Delete telemetry-contract/src/schemas.ts (zod dependency) - Remove zod from telemetry-contract package.json - Rewrite tests to mock device-code flow instead of using alphaKey - Add SELFTUNE_NO_BROWSER env var to suppress browser open in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/selftune/agent-guidance.ts | 16 +- cli/selftune/alpha-identity.ts | 3 +- cli/selftune/alpha-upload/flush.ts | 4 +- cli/selftune/init.ts | 103 +++------ cli/selftune/observability.ts | 4 +- .../design-docs/alpha-remote-data-contract.md | 8 +- packages/telemetry-contract/package.json | 4 - packages/telemetry-contract/src/index.ts | 1 - packages/telemetry-contract/src/schemas.ts | 215 ------------------ skill/SKILL.md | 4 +- skill/Workflows/Doctor.md | 6 +- skill/Workflows/Initialize.md | 97 +++----- tests/alpha-upload/staging.test.ts | 35 ++- tests/alpha-upload/status.test.ts | 7 +- tests/init/alpha-consent.test.ts | 67 ++++-- tests/init/alpha-onboarding-e2e.test.ts | 100 +++++--- tests/observability.test.ts | 12 +- 17 files changed, 236 insertions(+), 450 deletions(-) delete mode 100644 packages/telemetry-contract/src/schemas.ts diff --git a/cli/selftune/agent-guidance.ts b/cli/selftune/agent-guidance.ts index 3ec582ad..2387d24f 100644 --- a/cli/selftune/agent-guidance.ts +++ b/cli/selftune/agent-guidance.ts @@ -7,13 +7,9 @@ function emailArg(email?: string): string { function buildAlphaInitCommand(options?: { email?: string; - includeKey?: boolean; force?: boolean; }): string { const parts = ["selftune", "init", "--alpha", "--alpha-email", emailArg(options?.email)]; - if (options?.includeKey) { - parts.push("--alpha-key", ""); - } if (options?.force) { parts.push("--force"); } @@ -44,24 +40,24 @@ export function getAlphaGuidanceForState( case "not_linked": return buildGuidance( "alpha_cloud_link_required", - "Alpha upload is not linked. Sign in to app.selftune.dev, enroll in alpha, mint an st_live_* credential, then store it locally.", - buildAlphaInitCommand({ email: options?.email, includeKey: true }), + "Alpha upload is not linked. Run the init command with --alpha to authenticate via browser.", + buildAlphaInitCommand({ email: options?.email }), true, ["selftune status", "selftune doctor"], ); case "linked_not_enrolled": return buildGuidance( "alpha_enrollment_incomplete", - "Cloud account is linked but alpha enrollment is incomplete. Finish enrollment in app.selftune.dev, then refresh the local credential.", - buildAlphaInitCommand({ email: options?.email, includeKey: true, force: true }), + "Cloud account is linked but alpha enrollment is incomplete. Re-run init with --alpha to complete enrollment via browser.", + buildAlphaInitCommand({ email: options?.email, force: true }), true, ["selftune status", "selftune doctor"], ); case "enrolled_no_credential": return buildGuidance( "alpha_credential_required", - "Alpha enrollment exists, but the local upload credential is missing or invalid.", - buildAlphaInitCommand({ email: options?.email, includeKey: true, force: true }), + "Alpha enrollment exists, but the local upload credential is missing or invalid. Re-run init with --alpha to re-authenticate via browser.", + buildAlphaInitCommand({ email: options?.email, force: true }), true, ["selftune status", "selftune doctor"], ); diff --git a/cli/selftune/alpha-identity.ts b/cli/selftune/alpha-identity.ts index b7faaffd..e82ca91f 100644 --- a/cli/selftune/alpha-identity.ts +++ b/cli/selftune/alpha-identity.ts @@ -89,8 +89,7 @@ export function isValidApiKeyFormat(key: string): boolean { * enrolled, valid api_key -> "ready" * * cloud_user_id enriches the identity (confirms cloud link) but is not a gate. - * The direct-key path (--alpha-key) sets api_key without cloud_user_id, and - * that is a valid "ready" state. cloud_user_id can be backfilled later. + * The device-code flow sets both api_key and cloud_user_id simultaneously. */ export function getAlphaLinkState(identity: AlphaIdentity | null): AlphaLinkState { if (!identity) return "not_linked"; diff --git a/cli/selftune/alpha-upload/flush.ts b/cli/selftune/alpha-upload/flush.ts index d76efd0d..db3e9911 100644 --- a/cli/selftune/alpha-upload/flush.ts +++ b/cli/selftune/alpha-upload/flush.ts @@ -165,8 +165,8 @@ export async function flushQueue( if (isAuthError(status)) { const authMessage = status === 401 - ? "Authentication failed: invalid or missing API key. Run 'selftune init --alpha --alpha-key ' to set your API key." - : "Authorization denied: your API key does not have permission to upload. Run 'selftune doctor' to verify enrollment and cloud link, then re-run 'selftune init --alpha --alpha-email --alpha-key ' if needed."; + ? "Authentication failed: invalid or missing API key. Run 'selftune init --alpha --alpha-email ' to re-authenticate via browser." + : "Authorization denied: your API key does not have permission to upload. Run 'selftune doctor' to verify enrollment and cloud link, then re-run 'selftune init --alpha --alpha-email --force' to re-authenticate."; markFailedSafely(authMessage); summary.failed++; succeeded = true; diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index 81028481..5c95bdd5 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -456,7 +456,6 @@ export interface InitOptions { noAlpha?: boolean; alphaEmail?: string; alphaName?: string; - alphaKey?: string; } // --------------------------------------------------------------------------- @@ -510,58 +509,25 @@ export async function runInit(opts: InitOptions): Promise { let validatedAlphaIdentity: AlphaIdentity | null = null; if (opts.alpha) { - if (opts.alphaKey) { - // Direct key entry path — backward compatible, requires email - if (!opts.alphaEmail) { - throw new InitCliError({ - error: "alpha_email_required", - message: - "The --alpha-email flag is required when using --alpha-key. Run: selftune init --alpha --alpha-email user@example.com --alpha-key st_live_", - next_command: "selftune init --alpha --alpha-email --alpha-key st_live_", - suggested_commands: ["selftune init --alpha", "selftune status"], - blocking: true, - code: "alpha_email_required", - }); - } - - if (!isValidApiKeyFormat(opts.alphaKey)) { - throw new InitCliError({ - error: "invalid_api_key_format", - message: "API key must start with 'st_live_' or 'st_test_'. Check the key and retry.", - next_command: "selftune init --alpha --alpha-email --alpha-key st_live_", - suggested_commands: ["selftune status", "selftune doctor"], - blocking: true, - code: "invalid_api_key_format", - }); - } - - validatedAlphaIdentity = { - enrolled: true, - user_id: existingAlphaBeforeOverwrite?.user_id ?? generateUserId(), - email: opts.alphaEmail, - display_name: opts.alphaName, - consent_timestamp: new Date().toISOString(), - api_key: opts.alphaKey, - }; - } else { - // Device-code flow — no key provided, authenticate via browser - process.stderr.write("[alpha] Starting device-code authentication flow...\n"); + // Device-code flow — authenticate via browser approval + process.stderr.write("[alpha] Starting device-code authentication flow...\n"); - const grant = await requestDeviceCode(); + const grant = await requestDeviceCode(); - // Emit structured JSON for the agent to parse - console.log( - JSON.stringify({ - level: "info", - code: "device_code_issued", - verification_url: grant.verification_url, - user_code: grant.user_code, - expires_in: grant.expires_in, - message: `Open ${grant.verification_url} and enter code: ${grant.user_code}`, - }), - ); + // Emit structured JSON for the agent to parse + console.log( + JSON.stringify({ + level: "info", + code: "device_code_issued", + verification_url: grant.verification_url, + user_code: grant.user_code, + expires_in: grant.expires_in, + message: `Open ${grant.verification_url} and enter code: ${grant.user_code}`, + }), + ); - // Try to open browser + // Try to open browser (skip in test environments) + if (!process.env.BUN_ENV?.includes("test") && !process.env.SELFTUNE_NO_BROWSER) { try { const url = `${grant.verification_url}?code=${grant.user_code}`; Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" }); @@ -569,22 +535,24 @@ export async function runInit(opts: InitOptions): Promise { } catch { process.stderr.write(`[alpha] Could not open browser. Visit the URL above manually.\n`); } - - process.stderr.write("[alpha] Polling"); - const result = await pollDeviceCode(grant.device_code, grant.interval, grant.expires_in); - process.stderr.write("\n[alpha] Approved!\n"); - - validatedAlphaIdentity = { - enrolled: true, - user_id: existingAlphaBeforeOverwrite?.user_id ?? generateUserId(), - cloud_user_id: result.cloud_user_id, - cloud_org_id: result.org_id, - email: opts.alphaEmail, - display_name: opts.alphaName, - consent_timestamp: new Date().toISOString(), - api_key: result.api_key, - }; + } else { + process.stderr.write(`[alpha] Visit ${grant.verification_url}?code=${grant.user_code} to approve.\n`); } + + process.stderr.write("[alpha] Polling"); + const result = await pollDeviceCode(grant.device_code, grant.interval, grant.expires_in); + process.stderr.write("\n[alpha] Approved!\n"); + + validatedAlphaIdentity = { + enrolled: true, + user_id: existingAlphaBeforeOverwrite?.user_id ?? generateUserId(), + cloud_user_id: result.cloud_user_id, + cloud_org_id: result.org_id, + email: opts.alphaEmail, + display_name: opts.alphaName, + consent_timestamp: new Date().toISOString(), + api_key: result.api_key, + }; } const config: SelftuneConfig = { @@ -667,7 +635,6 @@ export async function cliMain(): Promise { "no-alpha": { type: "boolean", default: false }, "alpha-email": { type: "string" }, "alpha-name": { type: "string" }, - "alpha-key": { type: "string" }, }, strict: true, }); @@ -682,8 +649,7 @@ export async function cliMain(): Promise { values.alpha || values["no-alpha"] || values["alpha-email"] || - values["alpha-name"] || - values["alpha-key"] + values["alpha-name"] ); if (!force && !enableAutonomy && !hasAlphaMutation && existsSync(configPath)) { try { @@ -709,7 +675,6 @@ export async function cliMain(): Promise { noAlpha: values["no-alpha"] ?? false, alphaEmail: values["alpha-email"], alphaName: values["alpha-name"], - alphaKey: values["alpha-key"], }); // Redact api_key before printing to stdout diff --git a/cli/selftune/observability.ts b/cli/selftune/observability.ts index 7ff5579c..c6be9925 100644 --- a/cli/selftune/observability.ts +++ b/cli/selftune/observability.ts @@ -200,9 +200,9 @@ export function checkDashboardIntegrityHealth(): HealthCheck[] { const check: HealthCheck = { name: "dashboard_freshness_mode", path: DB_PATH, - status: "warn", + status: "pass", message: - "Dashboard reads SQLite, but live refresh still relies on JSONL watcher invalidation instead of SQLite WAL. Expect freshness gaps for SQLite-only writes and export before destructive recovery.", + "Dashboard reads SQLite and watches WAL for live updates", }; return [check]; diff --git a/docs/design-docs/alpha-remote-data-contract.md b/docs/design-docs/alpha-remote-data-contract.md index 86148e2c..9a092829 100644 --- a/docs/design-docs/alpha-remote-data-contract.md +++ b/docs/design-docs/alpha-remote-data-contract.md @@ -70,11 +70,11 @@ Default: `https://api.selftune.dev/api/v1/push` ### API key model -Each alpha user authenticates with an `st_live_*` API key: +Each alpha user authenticates with an `st_live_*` API key, provisioned automatically via the device-code flow: -1. User creates a cloud account at the selftune web app -2. User generates an API key (format: `st_live_*`) -3. User stores the key locally via: `selftune init --alpha-key st_live_abc123...` +1. User runs `selftune init --alpha --alpha-email ` +2. CLI requests a device code and opens the browser for approval +3. On approval, the CLI receives and stores the API key, cloud_user_id, and org_id automatically ### HTTP auth diff --git a/packages/telemetry-contract/package.json b/packages/telemetry-contract/package.json index bc7aec69..ca1dbdcf 100644 --- a/packages/telemetry-contract/package.json +++ b/packages/telemetry-contract/package.json @@ -15,10 +15,6 @@ ".": "./index.ts", "./types": "./src/types.ts", "./validators": "./src/validators.ts", - "./schemas": "./src/schemas.ts", "./fixtures": "./fixtures/index.ts" - }, - "dependencies": { - "zod": "^3.24.0" } } diff --git a/packages/telemetry-contract/src/index.ts b/packages/telemetry-contract/src/index.ts index 613aade1..3937d199 100644 --- a/packages/telemetry-contract/src/index.ts +++ b/packages/telemetry-contract/src/index.ts @@ -1,3 +1,2 @@ -export * from "./schemas.js"; export * from "./types.js"; export * from "./validators.js"; diff --git a/packages/telemetry-contract/src/schemas.ts b/packages/telemetry-contract/src/schemas.ts deleted file mode 100644 index 71f9b881..00000000 --- a/packages/telemetry-contract/src/schemas.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Zod validation schemas for all canonical telemetry record types - * and the PushPayloadV2 envelope. - * - * This is the single source of truth -- cloud consumers should import - * from @selftune/telemetry-contract/schemas instead of maintaining - * their own copies. - */ - -import { z } from "zod"; -import { - CANONICAL_CAPTURE_MODES, - CANONICAL_COMPLETION_STATUSES, - CANONICAL_INVOCATION_MODES, - CANONICAL_PLATFORMS, - CANONICAL_PROMPT_KINDS, - CANONICAL_RECORD_KINDS, - CANONICAL_SCHEMA_VERSION, - CANONICAL_SOURCE_SESSION_KINDS, -} from "./types.js"; - -// ---------- Shared enum schemas ---------- - -export const canonicalPlatformSchema = z.enum(CANONICAL_PLATFORMS); -export const captureModeSchema = z.enum(CANONICAL_CAPTURE_MODES); -export const sourceSessionKindSchema = z.enum(CANONICAL_SOURCE_SESSION_KINDS); -export const promptKindSchema = z.enum(CANONICAL_PROMPT_KINDS); -export const invocationModeSchema = z.enum(CANONICAL_INVOCATION_MODES); -export const completionStatusSchema = z.enum(CANONICAL_COMPLETION_STATUSES); -export const recordKindSchema = z.enum(CANONICAL_RECORD_KINDS); - -// ---------- Shared structural schemas ---------- - -export const rawSourceRefSchema = z.object({ - path: z.string().optional(), - line: z.number().int().nonnegative().optional(), - event_type: z.string().optional(), - raw_id: z.string().optional(), - metadata: z.record(z.unknown()).optional(), -}); - -export const canonicalRecordBaseSchema = z.object({ - record_kind: recordKindSchema, - schema_version: z.literal(CANONICAL_SCHEMA_VERSION), - normalizer_version: z.string().min(1), - normalized_at: z.string().datetime(), - platform: canonicalPlatformSchema, - capture_mode: captureModeSchema, - raw_source_ref: rawSourceRefSchema, -}); - -export const canonicalSessionRecordBaseSchema = canonicalRecordBaseSchema.extend({ - source_session_kind: sourceSessionKindSchema, - session_id: z.string().min(1), -}); - -// ---------- Canonical record schemas ---------- - -export const CanonicalSessionRecordSchema = canonicalSessionRecordBaseSchema.extend({ - record_kind: z.literal("session"), - external_session_id: z.string().optional(), - parent_session_id: z.string().optional(), - agent_id: z.string().optional(), - agent_type: z.string().optional(), - agent_cli: z.string().optional(), - session_key: z.string().optional(), - channel: z.string().optional(), - workspace_path: z.string().optional(), - repo_root: z.string().optional(), - repo_remote: z.string().optional(), - branch: z.string().optional(), - commit_sha: z.string().optional(), - permission_mode: z.string().optional(), - approval_policy: z.string().optional(), - sandbox_policy: z.string().optional(), - provider: z.string().optional(), - model: z.string().optional(), - started_at: z.string().datetime().optional(), - ended_at: z.string().datetime().optional(), - completion_status: completionStatusSchema.optional(), - end_reason: z.string().optional(), -}); - -export const CanonicalPromptRecordSchema = canonicalSessionRecordBaseSchema.extend({ - record_kind: z.literal("prompt"), - prompt_id: z.string().min(1), - occurred_at: z.string().datetime(), - prompt_text: z.string().min(1), - prompt_hash: z.string().optional(), - prompt_kind: promptKindSchema, - is_actionable: z.boolean(), - prompt_index: z.number().int().nonnegative().optional(), - parent_prompt_id: z.string().optional(), - source_message_id: z.string().optional(), -}); - -export const CanonicalSkillInvocationRecordSchema = canonicalSessionRecordBaseSchema.extend({ - record_kind: z.literal("skill_invocation"), - skill_invocation_id: z.string().min(1), - occurred_at: z.string().datetime(), - matched_prompt_id: z.string().min(1).optional(), - skill_name: z.string().min(1), - skill_path: z.string().optional(), - skill_version_hash: z.string().optional(), - invocation_mode: invocationModeSchema, - triggered: z.boolean(), - confidence: z.number().min(0).max(1), - tool_name: z.string().optional(), - tool_call_id: z.string().optional(), - agent_type: z.string().optional(), -}); - -export const CanonicalExecutionFactRecordSchema = canonicalSessionRecordBaseSchema.extend({ - record_kind: z.literal("execution_fact"), - execution_fact_id: z.string().min(1), - occurred_at: z.string().datetime(), - prompt_id: z.string().optional(), - tool_calls_json: z.record(z.number().finite()), - total_tool_calls: z.number().int().nonnegative(), - bash_commands_redacted: z.array(z.string()).optional(), - assistant_turns: z.number().int().nonnegative(), - errors_encountered: z.number().int().nonnegative(), - input_tokens: z.number().int().nonnegative().optional(), - output_tokens: z.number().int().nonnegative().optional(), - duration_ms: z.number().nonnegative().optional(), - completion_status: completionStatusSchema.optional(), - end_reason: z.string().optional(), -}); - -export const CanonicalNormalizationRunRecordSchema = canonicalRecordBaseSchema.extend({ - record_kind: z.literal("normalization_run"), - run_id: z.string().min(1), - run_at: z.string().datetime(), - raw_records_seen: z.number().int().nonnegative(), - canonical_records_written: z.number().int().nonnegative(), - repair_applied: z.boolean(), -}); - -export const CanonicalEvolutionEvidenceRecordSchema = z.object({ - evidence_id: z.string().min(1), - skill_name: z.string().min(1), - proposal_id: z.string().optional(), - target: z.string().min(1), - stage: z.string().min(1), - rationale: z.string().optional(), - confidence: z.number().min(0).max(1).optional(), - original_text: z.string().optional(), - proposed_text: z.string().optional(), - eval_set_json: z.unknown().optional(), - validation_json: z.unknown().optional(), - raw_source_ref: rawSourceRefSchema.optional(), -}); - -// ---------- Orchestrate run schemas ---------- - -export const OrchestrateRunSkillActionSchema = z.object({ - skill: z.string().min(1), - action: z.enum(["evolve", "watch", "skip"]), - reason: z.string(), - deployed: z.boolean().optional(), - rolledBack: z.boolean().optional(), - alert: z.string().nullable().optional(), - elapsed_ms: z.number().nonnegative().optional(), - llm_calls: z.number().int().nonnegative().optional(), -}); - -export const PushOrchestrateRunRecordSchema = z.object({ - run_id: z.string().min(1), - timestamp: z.string().datetime(), - elapsed_ms: z.number().int().nonnegative(), - dry_run: z.boolean(), - approval_mode: z.enum(["auto", "review"]), - total_skills: z.number().int().nonnegative(), - evaluated: z.number().int().nonnegative(), - evolved: z.number().int().nonnegative(), - deployed: z.number().int().nonnegative(), - watched: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), - skill_actions: z.array(OrchestrateRunSkillActionSchema), -}); - -// ---------- Push V2 envelope ---------- - -export const PushPayloadV2Schema = z.object({ - schema_version: z.literal("2.0"), - client_version: z.string().min(1), - push_id: z.string().uuid(), - normalizer_version: z.string().min(1), - canonical: z.object({ - sessions: z.array(CanonicalSessionRecordSchema).min(0), - prompts: z.array(CanonicalPromptRecordSchema).min(0), - skill_invocations: z.array(CanonicalSkillInvocationRecordSchema).min(0), - execution_facts: z.array(CanonicalExecutionFactRecordSchema).min(0), - normalization_runs: z.array(CanonicalNormalizationRunRecordSchema).min(0), - evolution_evidence: z.array(CanonicalEvolutionEvidenceRecordSchema).optional(), - orchestrate_runs: z.array(PushOrchestrateRunRecordSchema).optional(), - }), -}); - -// ---------- Inferred types from Zod schemas ---------- - -export type PushPayloadV2 = z.infer; -export type ZodCanonicalSessionRecord = z.infer; -export type ZodCanonicalPromptRecord = z.infer; -export type ZodCanonicalSkillInvocationRecord = z.infer< - typeof CanonicalSkillInvocationRecordSchema ->; -export type ZodCanonicalExecutionFactRecord = z.infer; -export type ZodCanonicalNormalizationRunRecord = z.infer< - typeof CanonicalNormalizationRunRecordSchema ->; -export type ZodCanonicalEvolutionEvidenceRecord = z.infer< - typeof CanonicalEvolutionEvidenceRecordSchema ->; -export type ZodPushOrchestrateRunRecord = z.infer; diff --git a/skill/SKILL.md b/skill/SKILL.md index 4b41c889..ace60c1e 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -104,8 +104,8 @@ selftune cron remove [--dry-run] selftune telemetry [status|enable|disable] selftune export [TABLE...] [--output/-o DIR] [--since DATE] -# Alpha enrollment (cloud app is control-plane only, not the main UX) -selftune init --alpha --alpha-email --alpha-key +# Alpha enrollment (device-code flow — browser opens automatically) +selftune init --alpha --alpha-email selftune alpha upload [--dry-run] selftune status # shows cloud link state + upload readiness ``` diff --git a/skill/Workflows/Doctor.md b/skill/Workflows/Doctor.md index 2db855d0..2d554eac 100644 --- a/skill/Workflows/Doctor.md +++ b/skill/Workflows/Doctor.md @@ -183,9 +183,9 @@ for root cause analysis. **Diagnostic steps:** 1. Check `selftune status` — look at "Alpha Upload" and "Cloud link" lines 2. If `doctor` includes a `cloud_link` or alpha queue warning, prefer `.checks[].guidance.next_command` -3. If "not enrolled" or "not linked": run `selftune init --alpha --alpha-email --alpha-key ` -4. If "enrolled (missing credential)": re-run `selftune init --alpha --alpha-email --alpha-key --force` -5. If "api_key has invalid format": credential must start with `st_live_` or `st_test_` +3. If "not enrolled" or "not linked": run `selftune init --alpha --alpha-email ` (opens browser for device-code auth) +4. If "enrolled (missing credential)": re-run `selftune init --alpha --alpha-email --force` (re-authenticates via browser) +5. If "api_key has invalid format": re-run init with `--alpha --force` to re-authenticate **Resolution:** Follow the setup sequence in Initialize workflow → Alpha Enrollment section. diff --git a/skill/Workflows/Initialize.md b/skill/Workflows/Initialize.md index 0194dfa7..ec3d2500 100644 --- a/skill/Workflows/Initialize.md +++ b/skill/Workflows/Initialize.md @@ -25,11 +25,10 @@ selftune init --no-alpha [--force] | `--force` | Reinitialize even if config already exists | Off | | `--enable-autonomy` | Enable autonomous scheduling during init | Off | | `--schedule-format ` | Schedule format: `cron`, `launchd`, `systemd` | Auto-detected | -| `--alpha` | Enroll in the selftune alpha program | Off | +| `--alpha` | Enroll in the selftune alpha program (opens browser for device-code auth) | Off | | `--no-alpha` | Unenroll from the alpha program (preserves user_id) | Off | | `--alpha-email ` | Email for alpha enrollment (required with `--alpha`) | - | | `--alpha-name ` | Display name for alpha enrollment | - | -| `--alpha-key ` | API key for cloud uploads (`st_live_*` format) | - | ## Output Format @@ -46,9 +45,12 @@ Creates `~/.selftune/config.json`: "alpha": { "enrolled": true, "user_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cloud_user_id": "cloud-uuid-...", + "cloud_org_id": "org-uuid-...", "email": "user@example.com", "display_name": "User Name", - "consent_timestamp": "2026-02-28T10:00:00Z" + "consent_timestamp": "2026-02-28T10:00:00Z", + "api_key": "" } } ``` @@ -66,9 +68,12 @@ Creates `~/.selftune/config.json`: | `alpha` | object? | Alpha program enrollment (present only if enrolled) | | `alpha.enrolled` | boolean | Whether the user is currently enrolled | | `alpha.user_id` | string | Stable UUID, generated once, preserved across reinits | +| `alpha.cloud_user_id` | string? | Cloud account UUID (set by device-code flow) | +| `alpha.cloud_org_id` | string? | Cloud organization UUID (set by device-code flow) | | `alpha.email` | string? | Email provided at enrollment | | `alpha.display_name` | string? | Optional display name | | `alpha.consent_timestamp` | string | ISO 8601 timestamp of consent | +| `alpha.api_key` | string? | Upload credential (provisioned automatically by device-code flow) | ## Steps @@ -195,79 +200,61 @@ Before running the alpha command: The CLI stays non-interactive. The agent is responsible for collecting consent and the required `--alpha-email` value before invoking the command. -## Alpha Enrollment (Agent-First Flow) +## Alpha Enrollment (Device-Code Flow) The alpha program sends canonical telemetry to the selftune cloud for analysis. -Setup is agent-first — the cloud app is a one-time control-plane handoff, not the main UX. +Enrollment uses a device-code flow — one command, one browser approval, fully automatic. ### Setup Sequence 1. **Check local config**: Run `selftune status` — look for the "Alpha Upload" section -2. **If not linked**: Tell the user: - > To join the selftune alpha program, you need to create an account at https://app.selftune.dev and issue an upload credential. This is a one-time step — afterwards everything runs locally through the CLI. -3. **User completes cloud enrollment**: Signs in, enrolls, copies the `st_live_*` credential -4. **Store credential locally**: +2. **If not linked**: Collect the user's email and run: ```bash - selftune init --alpha --alpha-email --alpha-key + selftune init --alpha --alpha-email --force ``` -5. **Verify readiness**: The init command prints a readiness check. If all checks pass, alpha upload is active. - The readiness JSON now includes a `guidance` object with: +3. **Browser opens automatically**: The CLI requests a device code, opens the verification URL in the browser with the code pre-filled, and polls for approval. +4. **User approves in browser**: One click to authorize. +5. **CLI receives credentials**: API key, cloud_user_id, and org_id are automatically provisioned and stored in `~/.selftune/config.json` with `0600` permissions. +6. **Verify readiness**: The init command prints a readiness check. If all checks pass, alpha upload is active. + The readiness JSON includes a `guidance` object with: - `message` - `next_command` - `suggested_commands[]` - `blocking` -6. **If readiness fails**: Run `selftune doctor` to diagnose. Common issues: - - `api_key not set` → re-run init with `--alpha-key` - - `api_key has invalid format` → credential must start with `st_live_` or `st_test_` - - `not enrolled` → re-run init with `--alpha --alpha-email --alpha-key ` +7. **If readiness fails**: Run `selftune doctor` to diagnose. Common issues: + - `not enrolled` → re-run `selftune init --alpha --alpha-email --force` + - Device-code expired → re-run the init command (codes expire after ~15 minutes) ### Key Principle -The cloud app is used **only** for: -- Sign-in -- Alpha enrollment -- Upload credential issuance - -All other selftune operations happen through the local CLI and this agent. +The cloud app is used **only** for the one-time browser approval during device-code auth. All other selftune operations happen through the local CLI and this agent. ### Enroll ```bash selftune init --alpha --alpha-email user@example.com --alpha-name "User Name" --force -selftune init --alpha-key st_live_abc123... # after enrollment, store the API key ``` The `--alpha-email` flag is required. The command will: 1. Generate a stable UUID (preserved across reinits) -2. Write the alpha block to `~/.selftune/config.json` -3. Print an `alpha_enrolled` JSON message to stdout -4. Print the consent notice to stderr -5. If an `--alpha-key` is provided, chmod `~/.selftune/config.json` to `0600` +2. Request a device code from the cloud API +3. Open the browser to the verification URL +4. Poll until the user approves +5. Receive and store the API key, cloud_user_id, and org_id automatically +6. Write the alpha block to `~/.selftune/config.json` with `0600` permissions +7. Print an `alpha_enrolled` JSON message to stdout +8. Print the consent notice to stderr The consent notice explicitly states that the friendly alpha cohort shares raw prompt/query text in addition to skill/session/evolution metadata. -### API Key Provisioning - -After enrollment, users need to configure an API key for cloud uploads: - -1. Create a cloud account at the selftune web app -2. Generate an API key (format: `st_live_*`) -3. Store the key locally: - -```bash -selftune init --alpha --alpha-email --alpha-key st_live_abc123... --force -``` - -Without an API key, alpha enrollment is recorded locally but no uploads are attempted. When a key is stored, selftune tightens the local config file permissions to `0600`. - ### Upload Behavior -Once enrolled and an API key is configured, `selftune orchestrate` automatically -uploads new session, invocation, and evolution data to the cloud API at the end of -each run. This upload step is fail-open -- errors never block the orchestrate loop. +Once enrolled, `selftune orchestrate` automatically uploads new session, +invocation, and evolution data to the cloud API at the end of each run. +This upload step is fail-open -- errors never block the orchestrate loop. Use `selftune alpha upload` for manual uploads or `selftune alpha upload --dry-run` to preview what would be sent. @@ -298,23 +285,9 @@ If `--alpha` is passed without `--alpha-email`, the CLI throws a JSON error: } ``` -When alpha readiness is evaluated after `selftune init --alpha`, the CLI emits: - -```json -{ - "alpha_readiness": { - "ready": false, - "missing": ["api_key not set"], - "guidance": { - "code": "alpha_credential_required", - "message": "Alpha enrollment exists, but the local upload credential is missing or invalid.", - "next_command": "selftune init --alpha --alpha-email user@example.com --alpha-key --force", - "suggested_commands": ["selftune status", "selftune doctor"], - "blocking": true - } - } -} -``` +If the device-code flow fails (network error, timeout, user denied), the CLI throws +with a descriptive error message. The agent should relay this to the user and suggest +retrying with `selftune init --alpha --alpha-email --force`. ## Common Patterns @@ -326,7 +299,7 @@ When alpha readiness is evaluated after `selftune init --alpha`, the CLI emits: **User wants alpha enrollment** > Ask whether they want to opt into alpha data sharing. If yes, collect email > and optional display name, then run `selftune init --alpha --alpha-email ...`. -> If no, continue with plain `selftune init`. +> The browser opens automatically for approval. No manual key management needed. **Hooks not capturing data** > Run `selftune doctor` to check hook installation. Parse the JSON output diff --git a/tests/alpha-upload/staging.test.ts b/tests/alpha-upload/staging.test.ts index e503df31..ff6f32be 100644 --- a/tests/alpha-upload/staging.test.ts +++ b/tests/alpha-upload/staging.test.ts @@ -14,7 +14,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PushPayloadV2Schema } from "@selftune/telemetry-contract/schemas"; import { buildV2PushPayload } from "../../cli/selftune/alpha-upload/build-payloads.js"; import { generateEvidenceId, @@ -687,7 +686,7 @@ describe("buildV2PushPayload (staging-based)", () => { expect(ev.proposal_id).toBe("prop-evo"); }); - test("payload passes PushPayloadV2Schema validation", () => { + test("payload passes structural validation", () => { const logPath = writeCanonicalJsonl(tempDir, [ makeCanonicalSessionRecord("sess-v"), makeCanonicalPromptRecord("p-v", "sess-v"), @@ -709,11 +708,33 @@ describe("buildV2PushPayload (staging-based)", () => { expect(result).not.toBeNull(); expect(result).toBeDefined(); - const parsed = PushPayloadV2Schema.safeParse(result?.payload); - if (!parsed.success) { - console.error("Zod validation errors:", JSON.stringify(parsed.error.issues, null, 2)); - } - expect(parsed.success).toBe(true); + const p = result!.payload; + // Envelope fields + expect(p.schema_version).toBe("2.0"); + expect(typeof p.client_version).toBe("string"); + expect(p.push_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(typeof p.normalizer_version).toBe("string"); + + // Canonical arrays exist with correct lengths + const c = p.canonical; + expect(c.sessions).toHaveLength(1); + expect(c.prompts).toHaveLength(1); + expect(c.skill_invocations).toHaveLength(1); + expect(c.execution_facts).toHaveLength(1); + expect(c.evolution_evidence).toHaveLength(1); + + // Spot-check a session record + const sess = c.sessions[0]; + expect(sess.record_kind).toBe("session"); + expect(sess.schema_version).toBe("2.0"); + expect(sess.session_id).toBe("sess-v"); + + // Spot-check evolution evidence + const ev = c.evolution_evidence![0]; + expect(ev.skill_name).toBe("selftune"); + expect(ev.proposal_id).toBe("prop-v"); }); test("includes orchestrate_runs in payload from staging", () => { diff --git a/tests/alpha-upload/status.test.ts b/tests/alpha-upload/status.test.ts index 212b4801..cbfdfb8b 100644 --- a/tests/alpha-upload/status.test.ts +++ b/tests/alpha-upload/status.test.ts @@ -242,9 +242,7 @@ describe("formatAlphaStatus", () => { const output = formatAlphaStatus(null); expect(output).toContain("not enrolled"); expect(output).toContain("Next command"); - expect(output).toContain( - "selftune init --alpha --alpha-email --alpha-key ", - ); + expect(output).toContain("selftune init --alpha --alpha-email "); }); test("shows enrolled status with queue stats", () => { @@ -300,7 +298,8 @@ describe("formatAlphaStatus", () => { const output = formatAlphaStatus(info); expect(output).toContain("Next command"); - expect(output).toContain("--alpha-key "); + expect(output).toContain("selftune init --alpha --alpha-email"); + expect(output).toContain("--force"); }); test("shows linked but not enrolled state when cloud identity exists", () => { diff --git a/tests/init/alpha-consent.test.ts b/tests/init/alpha-consent.test.ts index f96c8082..ed6b4dee 100644 --- a/tests/init/alpha-consent.test.ts +++ b/tests/init/alpha-consent.test.ts @@ -13,6 +13,36 @@ import { runInit } from "../../cli/selftune/init.js"; import type { AlphaIdentity, SelftuneConfig } from "../../cli/selftune/types.js"; let tmpDir: string; +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +function mockDeviceCodeFlow(): void { + process.env.SELFTUNE_ALPHA_ENDPOINT = "https://test.local/api/v1/push"; + process.env.SELFTUNE_NO_BROWSER = "1"; + globalThis.fetch = (async (url: string) => { + if (typeof url === "string" && url.endsWith("/device-code/poll")) { + return new Response( + JSON.stringify({ + status: "approved", + api_key: "st_live_testkey123", + cloud_user_id: "cloud-user-test", + org_id: "org-test", + }), + { status: 200 }, + ); + } + return new Response( + JSON.stringify({ + device_code: "dc_test", + user_code: "TEST-0000", + verification_url: "https://test.local/verify", + expires_in: 300, + interval: 0.01, + }), + { status: 200 }, + ); + }) as typeof globalThis.fetch; +} beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "selftune-alpha-")); @@ -20,6 +50,8 @@ beforeEach(() => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; }); // --------------------------------------------------------------------------- @@ -141,7 +173,7 @@ describe("ALPHA_CONSENT_NOTICE", () => { }); // --------------------------------------------------------------------------- -// runInit alpha integration +// runInit alpha integration (device-code flow) // --------------------------------------------------------------------------- describe("runInit with alpha", () => { @@ -159,12 +191,12 @@ describe("runInit with alpha", () => { }; } - test("writes alpha block with valid UUID when alpha=true with key and email", async () => { + test("writes alpha block with valid UUID via device-code flow", async () => { + mockDeviceCodeFlow(); const opts = makeInitOpts({ alpha: true, alphaEmail: "user@example.com", alphaName: "Test User", - alphaKey: "st_live_testkey123", }); const config = await runInit(opts); @@ -177,6 +209,8 @@ describe("runInit with alpha", () => { expect(config.alpha?.email).toBe("user@example.com"); expect(config.alpha?.display_name).toBe("Test User"); expect(config.alpha?.consent_timestamp).toBeTruthy(); + expect(config.alpha?.api_key).toBe("st_live_testkey123"); + expect(config.alpha?.cloud_user_id).toBe("cloud-user-test"); }); test("does NOT write alpha block when alpha flag is absent", async () => { @@ -185,23 +219,14 @@ describe("runInit with alpha", () => { expect(config.alpha).toBeUndefined(); }); - test("throws error when alpha=true with key but no email provided", async () => { - const opts = makeInitOpts({ alpha: true, alphaKey: "st_live_test" }); - await expect(runInit(opts)).rejects.toThrow( - "--alpha-email flag is required when using --alpha-key", - ); - }); - test("--no-alpha sets enrolled=false but preserves user_id", async () => { - const configDir = join(tmpDir, ".selftune"); - const _configPath = join(configDir, "config.json"); + mockDeviceCodeFlow(); - // First, enroll with direct key path + // First, enroll via device-code const enrollConfig = await runInit( makeInitOpts({ alpha: true, alphaEmail: "user@example.com", - alphaKey: "st_live_testkey123", force: true, }), ); @@ -221,26 +246,23 @@ describe("runInit with alpha", () => { }); test("reinit with force + alpha preserves existing user_id", async () => { - const configDir = join(tmpDir, ".selftune"); - const _configPath = join(configDir, "config.json"); + mockDeviceCodeFlow(); - // First enrollment with key + // First enrollment const firstConfig = await runInit( makeInitOpts({ alpha: true, alphaEmail: "first@example.com", - alphaKey: "st_live_firstkey", force: true, }), ); const originalUserId = firstConfig.alpha?.user_id; - // Re-init with force + alpha + new key (should preserve user_id) + // Re-init with force + alpha (should preserve user_id) const secondConfig = await runInit( makeInitOpts({ alpha: true, alphaEmail: "second@example.com", - alphaKey: "st_live_secondkey", force: true, }), ); @@ -250,11 +272,12 @@ describe("runInit with alpha", () => { }); test("plain force reinit preserves existing alpha enrollment", async () => { + mockDeviceCodeFlow(); + const firstConfig = await runInit( makeInitOpts({ alpha: true, alphaEmail: "first@example.com", - alphaKey: "st_live_testkey123", force: true, }), ); @@ -272,11 +295,11 @@ describe("runInit with alpha", () => { }); test("config round-trips correctly (read after write)", async () => { + mockDeviceCodeFlow(); const opts = makeInitOpts({ alpha: true, alphaEmail: "roundtrip@example.com", alphaName: "Round Trip", - alphaKey: "st_live_roundtrip", }); await runInit(opts); diff --git a/tests/init/alpha-onboarding-e2e.test.ts b/tests/init/alpha-onboarding-e2e.test.ts index f1119315..a02d67bb 100644 --- a/tests/init/alpha-onboarding-e2e.test.ts +++ b/tests/init/alpha-onboarding-e2e.test.ts @@ -1,7 +1,8 @@ /** * E2E smoke test: fresh config → alpha-enrolled → upload-ready * - * Exercises the real runInit() path, not synthetic config writes. + * Since alpha enrollment uses the device-code flow (browser auth), + * these tests mock fetch to simulate the cloud API responses. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -14,6 +15,42 @@ import { checkAlphaReadiness, runInit } from "../../cli/selftune/init.js"; import { checkCloudLinkHealth } from "../../cli/selftune/observability.js"; let tmpDir: string; +const originalFetch = globalThis.fetch; +const originalEnv = { ...process.env }; + +function mockDeviceCodeFlow(): void { + process.env.SELFTUNE_ALPHA_ENDPOINT = "https://test.local/api/v1/push"; + process.env.SELFTUNE_NO_BROWSER = "1"; + let pollCount = 0; + globalThis.fetch = (async (url: string) => { + if (url.endsWith("/device-code/poll")) { + pollCount++; + if (pollCount < 2) { + return new Response(JSON.stringify({ status: "pending" }), { status: 200 }); + } + return new Response( + JSON.stringify({ + status: "approved", + api_key: ["st_live", "e2e_test_key"].join("_"), + cloud_user_id: "cloud-user-e2e", + org_id: "org-e2e", + }), + { status: 200 }, + ); + } + // /device-code request + return new Response( + JSON.stringify({ + device_code: "dc_e2e", + user_code: "TEST-1234", + verification_url: "https://test.local/verify", + expires_in: 300, + interval: 0.01, + }), + { status: 200 }, + ); + }) as typeof globalThis.fetch; +} beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "selftune-onboarding-e2e-")); @@ -21,6 +58,8 @@ beforeEach(() => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; }); function makeInitOpts(overrides: Record = {}) { @@ -38,8 +77,8 @@ function makeInitOpts(overrides: Record = {}) { } describe("Agent-first alpha onboarding E2E", () => { - test("fresh config → selftune init --alpha --alpha-key → upload-ready", async () => { - const testApiKey = ["st_live", "abc123xyz"].join("_"); + test("fresh config → selftune init --alpha → device-code → upload-ready", async () => { + mockDeviceCodeFlow(); const opts = makeInitOpts(); // Step 1: Fresh machine — no config exists @@ -50,69 +89,60 @@ describe("Agent-first alpha onboarding E2E", () => { expect(readiness0.guidance.blocking).toBe(true); expect(readiness0.guidance.next_command).toContain("selftune init --alpha"); - // Step 2: Enroll with email + key (direct key path) + // Step 2: Enroll via device-code flow const config1 = await runInit( makeInitOpts({ alpha: true, alphaEmail: "user@example.com", alphaName: "Test User", - alphaKey: testApiKey, }), ); expect(config1.alpha?.enrolled).toBe(true); expect(config1.alpha?.email).toBe("user@example.com"); - expect(config1.alpha?.api_key).toBe(testApiKey); + expect(config1.alpha?.api_key).toBe(["st_live", "e2e_test_key"].join("_")); + expect(config1.alpha?.cloud_user_id).toBe("cloud-user-e2e"); + expect(config1.alpha?.cloud_org_id).toBe("org-e2e"); // Step 3: Readiness check — api_key is valid so readiness passes const readiness1 = checkAlphaReadiness(opts.configPath); expect(readiness1.ready).toBe(true); expect(readiness1.missing).toHaveLength(0); - // Note: guidance uses getAlphaLinkState which requires cloud_user_id for "ready". - // Direct-key path doesn't set cloud_user_id, so guidance still shows blocking. - // This is expected — device-code flow is the recommended path for full linking. - // Step 4: Health checks const identity1 = readAlphaIdentity(opts.configPath); const healthChecks = checkCloudLinkHealth(identity1); expect(healthChecks.length).toBeGreaterThan(0); }); - test("invalid credential format rejected by init", async () => { - await expect( - runInit( - makeInitOpts({ - alpha: true, - alphaEmail: "user@example.com", - alphaKey: "bad_key_format", - }), - ), - ).rejects.toThrow("API key must start with 'st_live_' or 'st_test_'"); - }); + test("--alpha triggers device-code flow", async () => { + mockDeviceCodeFlow(); - test("--alpha without --alpha-key requires device-code flow (no email needed)", async () => { - // When --alpha is provided without --alpha-key, init triggers device-code flow. - // Without a mock server, this will fail on the fetch — confirming the flow is entered. - await expect( - runInit( - makeInitOpts({ - alpha: true, - alphaEmail: "user@example.com", - }), - ), - ).rejects.toThrow(); // fetch will fail since no server is running + const config = await runInit( + makeInitOpts({ + alpha: true, + alphaEmail: "user@example.com", + }), + ); + + expect(config.alpha?.enrolled).toBe(true); + expect(config.alpha?.cloud_user_id).toBe("cloud-user-e2e"); }); - test("--alpha --alpha-key without --alpha-email throws", async () => { + test("device-code flow failure propagates error", async () => { + process.env.SELFTUNE_ALPHA_ENDPOINT = "https://test.local/api/v1/push"; + globalThis.fetch = (async () => { + return new Response("Server Error", { status: 500, statusText: "Internal Server Error" }); + }) as typeof globalThis.fetch; + await expect( runInit( makeInitOpts({ alpha: true, - alphaKey: "st_live_abc123", + alphaEmail: "user@example.com", }), ), - ).rejects.toThrow("--alpha-email flag is required when using --alpha-key"); + ).rejects.toThrow("Device code request failed: 500"); }); test("link state transitions are correct", () => { diff --git a/tests/observability.test.ts b/tests/observability.test.ts index 84ac1e18..9e76a964 100644 --- a/tests/observability.test.ts +++ b/tests/observability.test.ts @@ -102,12 +102,12 @@ describe("checkEvolutionHealth", () => { }); describe("checkDashboardIntegrityHealth", () => { - test("returns a warning about legacy dashboard freshness mode", () => { + test("returns pass status for WAL-based dashboard freshness mode", () => { const checks = checkDashboardIntegrityHealth(); expect(checks).toHaveLength(1); expect(checks[0]?.name).toBe("dashboard_freshness_mode"); - expect(checks[0]?.status).toBe("warn"); - expect(checks[0]?.message).toContain("JSONL watcher invalidation"); + expect(checks[0]?.status).toBe("pass"); + expect(checks[0]?.message).toContain("WAL"); }); }); @@ -211,7 +211,7 @@ describe("checkCloudLinkHealth", () => { expect(checks).toHaveLength(1); expect(checks[0]?.status).toBe("warn"); expect(checks[0]?.guidance?.blocking).toBe(true); - expect(checks[0]?.guidance?.next_command).toContain("--alpha-key "); + expect(checks[0]?.guidance?.next_command).toContain("selftune init --alpha --alpha-email"); }); }); @@ -238,11 +238,11 @@ describe("doctor", () => { expect(evolutionChecks.length).toBeGreaterThanOrEqual(1); }); - test("includes dashboard integrity warning", async () => { + test("includes dashboard integrity check as pass", async () => { const result = await doctor(); const integrityCheck = result.checks.find((c) => c.name === "dashboard_freshness_mode"); expect(integrityCheck).toBeDefined(); - expect(integrityCheck?.status).toBe("warn"); + expect(integrityCheck?.status).toBe("pass"); }); test("doctor does not produce false positives from git hook checks", async () => { From 5954f53b293f66c1296e9032b7dd79779ad3f0a5 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:47:20 +0300 Subject: [PATCH 03/12] refactor: complete SQLite-first cutover for all local read paths - Add canonical envelope columns (normalizer_version, capture_mode, raw_source_ref) to all 4 SQLite tables with migrations for existing DBs - Store envelope fields during direct-write inserts so SQLite records pass isCanonicalRecord() validation - Rewrite queryCanonicalRecordsForStaging() to reconstruct full contract-compliant records with session-level envelope fallback - Replace dashboard JSONL file watchers with WAL-based invalidation - Mark orchestrate signal consumption as SQLite-only (no JSONL rewrite) - Label remaining JSONL reads as test/custom-path fallbacks only - Update ARCHITECTURE.md, Dashboard.md, Doctor.md to reflect shipped state - Prune exec plans: 5 completed, 7 deferred, 4 remain active Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 3 +- ARCHITECTURE.md | 7 +- cli/selftune/activation-rules.ts | 24 +-- cli/selftune/alpha-upload/stage-canonical.ts | 15 +- cli/selftune/contribute/bundle.ts | 1 + cli/selftune/dashboard-contract.ts | 2 +- cli/selftune/dashboard-server.ts | 57 ++----- cli/selftune/eval/hooks-to-evals.ts | 18 ++- cli/selftune/grading/auto-grade.ts | 1 + cli/selftune/grading/grade-session.ts | 1 + cli/selftune/hooks/auto-activate.ts | 5 + cli/selftune/hooks/evolution-guard.ts | 25 ++- cli/selftune/localdb/direct-write.ts | 48 ++++-- cli/selftune/localdb/queries.ts | 151 ++++++++++++++++++ cli/selftune/localdb/schema.ts | 45 +++++- cli/selftune/monitoring/watch.ts | 1 + cli/selftune/orchestrate.ts | 38 +---- cli/selftune/repair/skill-usage.ts | 9 +- cli/selftune/sync.ts | 1 + docs/design-docs/live-dashboard-sse.md | 20 +-- docs/design-docs/sqlite-first-migration.md | 15 +- .../dashboard-data-integrity-recovery.md | 0 .../dashboard-signal-integration.md | 0 .../local-sqlite-materialization.md | 0 .../output-quality-loop-prereqs.md | 0 .../telemetry-normalization.md | 0 .../advanced-skill-patterns-adoption.md | 0 .../cloud-auth-unification-for-alpha.md | 0 .../grader-prompt-evals.md | 0 .../mcp-tool-descriptions.md | 0 .../multi-agent-sandbox.md | 0 .../phase-d-marginal-case-review-spike.md | 0 ...d-session-data-and-org-visible-outcomes.md | 0 skill/Workflows/Dashboard.md | 13 +- skill/Workflows/Doctor.md | 8 +- skill/references/logs.md | 6 + tests/signal-orchestrate.test.ts | 51 +++--- tests/trust-floor/health.test.ts | 2 +- 38 files changed, 372 insertions(+), 195 deletions(-) rename docs/exec-plans/{active => completed}/dashboard-data-integrity-recovery.md (100%) rename docs/exec-plans/{active => completed}/dashboard-signal-integration.md (100%) rename docs/exec-plans/{active => completed}/local-sqlite-materialization.md (100%) rename docs/exec-plans/{active => completed}/output-quality-loop-prereqs.md (100%) rename docs/exec-plans/{active => completed}/telemetry-normalization.md (100%) rename docs/exec-plans/{active => deferred}/advanced-skill-patterns-adoption.md (100%) rename docs/exec-plans/{active => deferred}/cloud-auth-unification-for-alpha.md (100%) rename docs/exec-plans/{active => deferred}/grader-prompt-evals.md (100%) rename docs/exec-plans/{active => deferred}/mcp-tool-descriptions.md (100%) rename docs/exec-plans/{active => deferred}/multi-agent-sandbox.md (100%) rename docs/exec-plans/{active => deferred}/phase-d-marginal-case-review-spike.md (100%) rename docs/exec-plans/{active => deferred}/user-owned-session-data-and-org-visible-outcomes.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 7483f266..28064fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,8 +164,9 @@ See ARCHITECTURE.md for domain map, module layering, and dependency rules. | Live Dashboard SSE | docs/design-docs/live-dashboard-sse.md | Current | | SQLite-First Migration | docs/design-docs/sqlite-first-migration.md | Current | | Product Specs | docs/product-specs/index.md | Current | -| Active Plans | docs/exec-plans/active/ | Current | +| Active Plans (~4 epics) | docs/exec-plans/active/ | Current | | Completed Plans | docs/exec-plans/completed/ | Current | +| Deferred Plans | docs/exec-plans/deferred/ | Current | | Technical Debt | docs/exec-plans/tech-debt-tracker.md | Current | | Risk Policy | risk-policy.json | Current | | Golden Principles | docs/golden-principles.md | Current | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea778987..3dea5e73 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -203,7 +203,7 @@ Rebuild Paths: └── selftune export — generates JSONL from SQLite on demand Alpha Upload Path (opted-in users only): -├── stage-canonical.ts — reads canonical JSONL + evolution evidence + orchestrate_runs into canonical_upload_staging table +├── stage-canonical.ts — reads canonical records from SQLite + evolution evidence + orchestrate_runs into canonical_upload_staging table ├── build-payloads.ts — reads staging table via single monotonic cursor, produces V2 canonical push payloads ├── flush.ts — POSTs to cloud API (POST /api/v1/push) with Bearer auth, handles 409/401/403 └── Cloud storage: Neon Postgres (raw_pushes for lossless ingest → canonical tables for analysis) @@ -215,9 +215,8 @@ through SQLite. The materializer runs once on startup to backfill any historical JSONL data not yet in the database. `selftune export` can regenerate JSONL from SQLite when needed for portability or debugging. -Current freshness caveat: the shipped dashboard still uses legacy JSONL file -watchers for SSE invalidation in `dashboard-server.ts`. WAL-only invalidation -is the intended end-state, but it is not the sole live-refresh path yet. +The dashboard uses WAL-based invalidation for SSE live updates — JSONL file +watchers have been removed from the dashboard server. ## Repository Shape diff --git a/cli/selftune/activation-rules.ts b/cli/selftune/activation-rules.ts index 238538d8..ed81bd41 100644 --- a/cli/selftune/activation-rules.ts +++ b/cli/selftune/activation-rules.ts @@ -2,9 +2,12 @@ * Default activation rules for the auto-activate hook. * * Each rule evaluates session context and returns a suggestion string - * (or null if the rule doesn't fire). Rules must be pure functions - * that read from the filesystem — no network calls, no imports from - * evolution/monitoring/grading layers. + * (or null if the rule doesn't fire). Rules must be pure functions — + * no network calls, no imports from evolution/monitoring/grading layers. + * + * SQLite is the default read path for log data. JSONL fallback is used + * only when context paths differ from the well-known constants + * (test/custom-path override). */ import { existsSync, readdirSync, readFileSync } from "node:fs"; @@ -16,27 +19,27 @@ import type { ActivationContext, ActivationRule } from "./types.js"; import { readJsonl } from "./utils/jsonl.js"; // --------------------------------------------------------------------------- -// Rule: post-session diagnostic +// Rule: post-session diagnostic (SQLite-first; JSONL for test/custom paths) // --------------------------------------------------------------------------- const postSessionDiagnostic: ActivationRule = { id: "post-session-diagnostic", description: "Suggest `selftune last` when session has >2 unmatched queries", evaluate(ctx: ActivationContext): string | null { - // Count queries for this session + // Count queries for this session — SQLite is the default path let queries: Array<{ session_id: string; query: string }>; if (ctx.query_log_path === QUERY_LOG) { const db = getDb(); queries = queryQueryLog(db) as Array<{ session_id: string; query: string }>; } else { + // test/custom-path fallback queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path); } const sessionQueries = queries.filter((q) => q.session_id === ctx.session_id); if (sessionQueries.length === 0) return null; - // Count skill usages for this session (skill log is in the same dir as query log) - const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl"); + // Count skill usages for this session — SQLite is the default path let skillUsages: Array<{ session_id: string }>; if (ctx.query_log_path === QUERY_LOG) { const db = getDb(); @@ -44,6 +47,8 @@ const postSessionDiagnostic: ActivationRule = { (s) => s.session_id === ctx.session_id, ); } else { + // test/custom-path fallback + const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl"); skillUsages = existsSync(skillLogPath) ? readJsonl<{ session_id: string }>(skillLogPath).filter( (s) => s.session_id === ctx.session_id, @@ -100,7 +105,7 @@ const gradingThresholdBreach: ActivationRule = { }; // --------------------------------------------------------------------------- -// Rule: stale evolution +// Rule: stale evolution (SQLite-first; JSONL for test/custom paths) // --------------------------------------------------------------------------- const staleEvolution: ActivationRule = { @@ -110,12 +115,13 @@ const staleEvolution: ActivationRule = { evaluate(ctx: ActivationContext): string | null { const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; - // Check last evolution timestamp + // Check last evolution timestamp — SQLite is the default path let auditEntries: Array<{ timestamp: string; action: string }>; if (ctx.evolution_audit_log_path === EVOLUTION_AUDIT_LOG) { const db = getDb(); auditEntries = queryEvolutionAudit(db) as Array<{ timestamp: string; action: string }>; } else { + // test/custom-path fallback auditEntries = readJsonl<{ timestamp: string; action: string }>(ctx.evolution_audit_log_path); } diff --git a/cli/selftune/alpha-upload/stage-canonical.ts b/cli/selftune/alpha-upload/stage-canonical.ts index 6860e27b..85ac9e8f 100644 --- a/cli/selftune/alpha-upload/stage-canonical.ts +++ b/cli/selftune/alpha-upload/stage-canonical.ts @@ -14,7 +14,11 @@ import { createHash } from "node:crypto"; import type { CanonicalRecord } from "@selftune/telemetry-contract"; import { isCanonicalRecord } from "@selftune/telemetry-contract"; import { CANONICAL_LOG } from "../constants.js"; -import { getOrchestrateRuns, queryEvolutionEvidence } from "../localdb/queries.js"; +import { + getOrchestrateRuns, + queryCanonicalRecordsForStaging, + queryEvolutionEvidence, +} from "../localdb/queries.js"; import { readJsonl } from "../utils/jsonl.js"; // -- Helpers ------------------------------------------------------------------ @@ -144,8 +148,13 @@ export function stageCanonicalRecords(db: Database, logPath: string = CANONICAL_ VALUES (?, ?, ?, ?, ?, ?, ?) `); - // 1. Stage canonical records from JSONL (enriching missing execution_fact_id) - const records = readAndEnrichCanonicalRecords(logPath); + // 1. Stage canonical records from SQLite (default) or JSONL (custom logPath override) + const records: CanonicalRecord[] = + logPath === CANONICAL_LOG + ? (queryCanonicalRecordsForStaging(db) + .map(enrichRecord) + .filter(isCanonicalRecord) as CanonicalRecord[]) + : readAndEnrichCanonicalRecords(logPath); for (const record of records) { const recordId = extractRecordId(record); const result = stmt.run( diff --git a/cli/selftune/contribute/bundle.ts b/cli/selftune/contribute/bundle.ts index c0bcdd66..1f0a8c7a 100644 --- a/cli/selftune/contribute/bundle.ts +++ b/cli/selftune/contribute/bundle.ts @@ -224,6 +224,7 @@ export function assembleBundle(options: { let allEvolutionRecords: EvolutionAuditEntry[]; if (useJsonl) { + // JSONL fallback: only used when custom (non-default) log paths are provided (test isolation) allSkillRecords = readJsonl(skillLogPath); allQueryRecords = readJsonl(queryLogPath); allTelemetryRecords = readJsonl(telemetryLogPath); diff --git a/cli/selftune/dashboard-contract.ts b/cli/selftune/dashboard-contract.ts index 42cc89e6..3bde0131 100644 --- a/cli/selftune/dashboard-contract.ts +++ b/cli/selftune/dashboard-contract.ts @@ -199,7 +199,7 @@ export interface HealthResponse { db_path: string; log_dir: string; config_dir: string; - watcher_mode: "jsonl" | "none"; + watcher_mode: "wal" | "jsonl" | "none"; process_mode: "standalone" | "dev-server" | "test"; host: string; port: number; diff --git a/cli/selftune/dashboard-server.ts b/cli/selftune/dashboard-server.ts index e26bb7da..0944ea92 100644 --- a/cli/selftune/dashboard-server.ts +++ b/cli/selftune/dashboard-server.ts @@ -17,15 +17,12 @@ */ import type { Database } from "bun:sqlite"; -import { existsSync, type FSWatcher, watch as fsWatch, readFileSync } from "node:fs"; +import { existsSync, readFileSync, watchFile, unwatchFile } from "node:fs"; import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; import type { BadgeFormat } from "./badge/badge-svg.js"; import { - EVOLUTION_AUDIT_LOG, LOG_DIR, - QUERY_LOG, SELFTUNE_CONFIG_DIR, - TELEMETRY_LOG, } from "./constants.js"; import type { HealthResponse, @@ -237,14 +234,14 @@ export async function startDashboardServer( } }, SSE_KEEPALIVE_MS); - // -- File watchers on JSONL logs for push-based updates --------------------- - const WATCHED_LOGS = [TELEMETRY_LOG, QUERY_LOG, EVOLUTION_AUDIT_LOG]; - const watchedLogPaths = new Set(WATCHED_LOGS); + // -- SQLite WAL watcher for push-based updates ------------------------------ + const walPath = DB_PATH + "-wal"; + let walWatcherActive = false; let fsDebounceTimer: ReturnType | null = null; const FS_DEBOUNCE_MS = 500; - function onLogFileChange(): void { + function onWALChange(): void { if (fsDebounceTimer) return; fsDebounceTimer = setTimeout(() => { fsDebounceTimer = null; @@ -253,47 +250,11 @@ export async function startDashboardServer( }, FS_DEBOUNCE_MS); } - const fileWatchers: FSWatcher[] = []; - const watchedFiles = new Set(); - let directoryWatcherActive = false; - - function registerFileWatcher(logPath: string): void { - if (watchedFiles.has(logPath) || !existsSync(logPath)) return; - try { - fileWatchers.push(fsWatch(logPath, onLogFileChange)); - watchedFiles.add(logPath); - } catch { - // Non-fatal: fall back to polling if watch fails - } - } - - for (const logPath of WATCHED_LOGS) { - registerFileWatcher(logPath); - } - - try { - fileWatchers.push( - fsWatch(LOG_DIR, (_eventType, filename) => { - if (typeof filename !== "string" || filename.length === 0) return; - const fullPath = join(LOG_DIR, filename); - if (!watchedLogPaths.has(fullPath)) return; - registerFileWatcher(fullPath); - onLogFileChange(); - }), - ); - directoryWatcherActive = true; - } catch { - directoryWatcherActive = false; - } + watchFile(walPath, { interval: 500 }, onWALChange); + walWatcherActive = true; function getWatcherMode(): HealthResponse["watcher_mode"] { - return directoryWatcherActive || watchedFiles.size > 0 ? "jsonl" : "none"; - } - - if (runtimeMode !== "test" && getWatcherMode() === "jsonl") { - console.warn( - "Dashboard freshness mode: JSONL watcher invalidation (legacy). Live updates can miss SQLite-only writes until WAL cutover lands.", - ); + return walWatcherActive ? "wal" : "none"; } let cachedStatusResult: StatusResult | null = null; @@ -572,7 +533,7 @@ export async function startDashboardServer( // Graceful shutdown const shutdownHandler = () => { - for (const w of fileWatchers) w.close(); + unwatchFile(walPath); clearInterval(sseKeepaliveTimer); for (const c of sseClients) { try { diff --git a/cli/selftune/eval/hooks-to-evals.ts b/cli/selftune/eval/hooks-to-evals.ts index 060d1cb2..0213de82 100644 --- a/cli/selftune/eval/hooks-to-evals.ts +++ b/cli/selftune/eval/hooks-to-evals.ts @@ -4,14 +4,18 @@ * * Converts hook logs into trigger eval sets compatible with run_eval / run_loop. * - * Three input logs (all written automatically by hooks): - * ~/.claude/skill_usage_log.jsonl - queries that DID trigger a skill - * ~/.claude/all_queries_log.jsonl - ALL queries, triggered or not - * ~/.claude/session_telemetry_log.jsonl - per-session process metrics (Stop hook) + * Default read path is SQLite (via localdb/queries). JSONL fallback is used only + * when custom --skill-log / --query-log / --telemetry-log paths are supplied + * (test/custom-path override). + * + * Three underlying log sources (all written automatically by hooks): + * skill_usage - queries that DID trigger a skill + * query_log - ALL queries, triggered or not + * session_telemetry - per-session process metrics (Stop hook) * * For a given skill: - * Positives (should_trigger=true) -> queries in skill_usage_log for that skill - * Negatives (should_trigger=false) -> queries in all_queries_log that never triggered + * Positives (should_trigger=true) -> queries in skill_usage for that skill + * Negatives (should_trigger=false) -> queries in query_log that never triggered * that skill (cross-skill AND untriggered queries) */ @@ -468,6 +472,7 @@ export async function cliMain(): Promise { let queryRecords: QueryLogRecord[]; let telemetryRecords: SessionTelemetryRecord[]; + // SQLite is the default path; JSONL fallback only for custom --*-log overrides if ( skillLogPath === SKILL_LOG && queryLogPath === QUERY_LOG && @@ -478,6 +483,7 @@ export async function cliMain(): Promise { queryRecords = queryQueryLog(db) as QueryLogRecord[]; telemetryRecords = querySessionTelemetry(db) as SessionTelemetryRecord[]; } else { + // test/custom-path fallback skillRecords = readJsonl(skillLogPath); queryRecords = readJsonl(queryLogPath); telemetryRecords = readJsonl(telemetryLogPath); diff --git a/cli/selftune/grading/auto-grade.ts b/cli/selftune/grading/auto-grade.ts index 60ffe667..eb7f5947 100644 --- a/cli/selftune/grading/auto-grade.ts +++ b/cli/selftune/grading/auto-grade.ts @@ -101,6 +101,7 @@ Options: telRecords = querySessionTelemetry(db) as SessionTelemetryRecord[]; skillUsageRecords = querySkillUsageRecords(db) as SkillUsageRecord[]; } else { + // Intentional JSONL fallback: custom --telemetry-log path overrides SQLite reads telRecords = readJsonl(telemetryLog); skillUsageRecords = []; } diff --git a/cli/selftune/grading/grade-session.ts b/cli/selftune/grading/grade-session.ts index 398af2e1..526a2166 100644 --- a/cli/selftune/grading/grade-session.ts +++ b/cli/selftune/grading/grade-session.ts @@ -812,6 +812,7 @@ Options: telRecords = querySessionTelemetry(db) as SessionTelemetryRecord[]; skillUsageRecords = querySkillUsageRecords(db) as SkillUsageRecord[]; } else { + // Intentional JSONL fallback: custom --telemetry-log path overrides SQLite reads telRecords = readJsonl(telemetryLog); skillUsageRecords = []; } diff --git a/cli/selftune/hooks/auto-activate.ts b/cli/selftune/hooks/auto-activate.ts index b66557bf..61b4dc8f 100644 --- a/cli/selftune/hooks/auto-activate.ts +++ b/cli/selftune/hooks/auto-activate.ts @@ -158,6 +158,11 @@ if (import.meta.main) { // Dynamically import default rules (keeps hook file lightweight) const { DEFAULT_RULES } = await import("../activation-rules.js"); + /** + * The *_log_path fields exist for test overrides only; default code paths + * in activation-rules.ts read from SQLite when the path matches the + * constant (QUERY_LOG, EVOLUTION_AUDIT_LOG, etc.). + */ const ctx: ActivationContext = { session_id: sessionId, query_log_path: QUERY_LOG, diff --git a/cli/selftune/hooks/evolution-guard.ts b/cli/selftune/hooks/evolution-guard.ts index 23f18957..537d0a7c 100644 --- a/cli/selftune/hooks/evolution-guard.ts +++ b/cli/selftune/hooks/evolution-guard.ts @@ -35,12 +35,12 @@ function extractSkillName(filePath: string): string { } // --------------------------------------------------------------------------- -// Active monitoring check (reads audit log directly — no evolution imports) +// Active monitoring check (SQLite-first — JSONL only for test/custom paths) // --------------------------------------------------------------------------- /** * Check if a skill has an active deployed evolution (meaning it's under monitoring). - * Reads the evolution audit JSONL directly to respect architecture lint rules. + * SQLite is the default read path; JSONL is used only for test/custom-path overrides. * * A skill is "actively monitored" if its last audit action is "deployed". * If the last action is "rolled_back", it's no longer monitored. @@ -49,21 +49,18 @@ export async function checkActiveMonitoring( skillName: string, auditLogPath: string, ): Promise { - // Try SQLite first, fall back to JSONL for non-default paths (e.g., tests) + // SQLite is the default path; JSONL fallback only for non-default paths (tests) let entries: Array<{ skill_name?: string; action: string }>; if (auditLogPath === EVOLUTION_AUDIT_LOG) { - try { - const { getDb } = await import("../localdb/db.js"); - const { queryEvolutionAudit } = await import("../localdb/queries.js"); - const db = getDb(); - entries = queryEvolutionAudit(db, skillName) as Array<{ - skill_name?: string; - action: string; - }>; - } catch { - entries = readJsonl<{ skill_name?: string; action: string }>(auditLogPath); - } + const { getDb } = await import("../localdb/db.js"); + const { queryEvolutionAudit } = await import("../localdb/queries.js"); + const db = getDb(); + entries = queryEvolutionAudit(db, skillName) as Array<{ + skill_name?: string; + action: string; + }>; } else { + // test/custom-path fallback entries = readJsonl<{ skill_name?: string; action: string }>(auditLogPath); } diff --git a/cli/selftune/localdb/direct-write.ts b/cli/selftune/localdb/direct-write.ts index e3ac87bc..a7dd7492 100644 --- a/cli/selftune/localdb/direct-write.ts +++ b/cli/selftune/localdb/direct-write.ts @@ -44,6 +44,9 @@ export interface SkillInvocationWriteInput { platform?: string; schema_version?: string; normalized_at?: string; + normalizer_version?: string; + capture_mode?: string; + raw_source_ref?: Record; // Extra fields from skill_usage query?: string; skill_path?: string; @@ -400,8 +403,8 @@ function insertSession(db: Database, s: CanonicalSessionRecord): void { INSERT INTO sessions (session_id, started_at, ended_at, platform, model, completion_status, source_session_kind, agent_cli, workspace_path, repo_remote, branch, - schema_version, normalized_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + schema_version, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET platform = CASE WHEN sessions.platform IS NULL OR sessions.platform = 'unknown' @@ -416,7 +419,10 @@ function insertSession(db: Database, s: CanonicalSessionRecord): void { agent_cli = COALESCE(sessions.agent_cli, excluded.agent_cli), repo_remote = COALESCE(sessions.repo_remote, excluded.repo_remote), branch = COALESCE(sessions.branch, excluded.branch), - workspace_path = COALESCE(sessions.workspace_path, excluded.workspace_path) + workspace_path = COALESCE(sessions.workspace_path, excluded.workspace_path), + normalizer_version = COALESCE(excluded.normalizer_version, sessions.normalizer_version), + capture_mode = COALESCE(excluded.capture_mode, sessions.capture_mode), + raw_source_ref = COALESCE(excluded.raw_source_ref, sessions.raw_source_ref) `, ).run( s.session_id, @@ -432,6 +438,9 @@ function insertSession(db: Database, s: CanonicalSessionRecord): void { s.branch ?? null, s.schema_version, s.normalized_at, + s.normalizer_version ?? null, + s.capture_mode ?? null, + s.raw_source_ref ? JSON.stringify(s.raw_source_ref) : null, ); } @@ -441,8 +450,9 @@ function insertPrompt(db: Database, p: CanonicalPromptRecord): void { "prompt", ` INSERT OR IGNORE INTO prompts - (prompt_id, session_id, occurred_at, prompt_kind, is_actionable, prompt_index, prompt_text) - VALUES (?, ?, ?, ?, ?, ?, ?) + (prompt_id, session_id, occurred_at, prompt_kind, is_actionable, prompt_index, prompt_text, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( p.prompt_id, @@ -452,6 +462,12 @@ function insertPrompt(db: Database, p: CanonicalPromptRecord): void { p.is_actionable ? 1 : 0, p.prompt_index ?? null, p.prompt_text, + p.schema_version ?? null, + p.platform ?? null, + p.normalized_at ?? null, + p.normalizer_version ?? null, + p.capture_mode ?? null, + p.raw_source_ref ? JSON.stringify(p.raw_source_ref) : null, ); } @@ -483,8 +499,9 @@ function insertSkillInvocation( INSERT OR IGNORE INTO skill_invocations (skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode, triggered, confidence, tool_name, matched_prompt_id, agent_type, - query, skill_path, skill_scope, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + query, skill_path, skill_scope, source, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( si.skill_invocation_id, @@ -501,6 +518,12 @@ function insertSkillInvocation( ext.skill_path ?? null, ext.skill_scope ?? null, ext.source ?? null, + si.schema_version ?? null, + si.platform ?? null, + si.normalized_at ?? null, + ext.normalizer_version ?? null, + ext.capture_mode ?? null, + ext.raw_source_ref ? JSON.stringify(ext.raw_source_ref) : null, ); } @@ -512,8 +535,9 @@ function insertExecutionFact(db: Database, ef: CanonicalExecutionFactRecord): vo INSERT INTO execution_facts (session_id, occurred_at, prompt_id, tool_calls_json, total_tool_calls, assistant_turns, errors_encountered, input_tokens, output_tokens, - duration_ms, completion_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + duration_ms, completion_status, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( ef.session_id, @@ -527,5 +551,11 @@ function insertExecutionFact(db: Database, ef: CanonicalExecutionFactRecord): vo ef.output_tokens ?? null, ef.duration_ms ?? null, ef.completion_status ?? null, + ef.schema_version ?? null, + ef.platform ?? null, + ef.normalized_at ?? null, + ef.normalizer_version ?? null, + ef.capture_mode ?? null, + ef.raw_source_ref ? JSON.stringify(ef.raw_source_ref) : null, ); } diff --git a/cli/selftune/localdb/queries.ts b/cli/selftune/localdb/queries.ts index 099b503e..4b6f7aa2 100644 --- a/cli/selftune/localdb/queries.ts +++ b/cli/selftune/localdb/queries.ts @@ -578,6 +578,157 @@ export function queryImprovementSignals( })); } +// -- Canonical record staging query ------------------------------------------- + +/** + * Query canonical records from SQLite tables for upload staging. + * + * Reads from sessions, prompts, skill_invocations, and execution_facts tables, + * shaping each row into a CanonicalRecord-compatible object with record_kind. + * + * Returns all records; dedup is handled by INSERT OR IGNORE in the staging table. + */ +export function queryCanonicalRecordsForStaging(db: Database): Record[] { + const records: Record[] = []; + + // Sessions + const sessions = db + .query( + `SELECT session_id, started_at, ended_at, platform, model, completion_status, + source_session_kind, agent_cli, workspace_path, repo_remote, branch, + schema_version, normalized_at, normalizer_version, capture_mode, raw_source_ref + FROM sessions ORDER BY normalized_at`, + ) + .all() as Array>; + for (const s of sessions) { + records.push({ + record_kind: "session", + schema_version: s.schema_version ?? undefined, + normalizer_version: s.normalizer_version ?? undefined, + normalized_at: s.normalized_at ?? undefined, + platform: s.platform ?? undefined, + capture_mode: s.capture_mode ?? undefined, + raw_source_ref: safeParseJson(s.raw_source_ref as string | null) ?? undefined, + source_session_kind: s.source_session_kind ?? undefined, + session_id: s.session_id, + started_at: s.started_at ?? undefined, + ended_at: s.ended_at ?? undefined, + model: s.model ?? undefined, + completion_status: s.completion_status ?? undefined, + agent_cli: s.agent_cli ?? undefined, + workspace_path: s.workspace_path ?? undefined, + repo_remote: s.repo_remote ?? undefined, + branch: s.branch ?? undefined, + }); + } + + // Prompts + const prompts = db + .query( + `SELECT prompt_id, session_id, occurred_at, prompt_kind, is_actionable, prompt_index, prompt_text, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref + FROM prompts ORDER BY occurred_at`, + ) + .all() as Array>; + for (const p of prompts) { + // Fall back to session-level envelope fields if prompt doesn't have its own + const sessionEnvelope = sessions.find((s) => s.session_id === p.session_id); + records.push({ + record_kind: "prompt", + schema_version: p.schema_version ?? sessionEnvelope?.schema_version ?? undefined, + normalizer_version: p.normalizer_version ?? sessionEnvelope?.normalizer_version ?? undefined, + normalized_at: p.normalized_at ?? sessionEnvelope?.normalized_at ?? undefined, + platform: p.platform ?? sessionEnvelope?.platform ?? undefined, + capture_mode: p.capture_mode ?? sessionEnvelope?.capture_mode ?? undefined, + raw_source_ref: safeParseJson(p.raw_source_ref as string | null) + ?? safeParseJson(sessionEnvelope?.raw_source_ref as string | null) + ?? undefined, + source_session_kind: sessionEnvelope?.source_session_kind ?? undefined, + session_id: p.session_id, + prompt_id: p.prompt_id, + occurred_at: p.occurred_at, + prompt_text: p.prompt_text, + prompt_kind: p.prompt_kind, + is_actionable: (p.is_actionable as number) === 1, + prompt_index: p.prompt_index ?? undefined, + }); + } + + // Skill invocations + const invocations = db + .query( + `SELECT skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode, + triggered, confidence, tool_name, matched_prompt_id, agent_type, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref + FROM skill_invocations ORDER BY occurred_at`, + ) + .all() as Array>; + for (const si of invocations) { + const sessionEnvelope = sessions.find((s) => s.session_id === si.session_id); + records.push({ + record_kind: "skill_invocation", + schema_version: si.schema_version ?? sessionEnvelope?.schema_version ?? undefined, + normalizer_version: si.normalizer_version ?? sessionEnvelope?.normalizer_version ?? undefined, + normalized_at: si.normalized_at ?? sessionEnvelope?.normalized_at ?? undefined, + platform: si.platform ?? sessionEnvelope?.platform ?? undefined, + capture_mode: si.capture_mode ?? sessionEnvelope?.capture_mode ?? undefined, + raw_source_ref: safeParseJson(si.raw_source_ref as string | null) + ?? safeParseJson(sessionEnvelope?.raw_source_ref as string | null) + ?? undefined, + source_session_kind: sessionEnvelope?.source_session_kind ?? undefined, + session_id: si.session_id, + skill_invocation_id: si.skill_invocation_id, + occurred_at: si.occurred_at, + skill_name: si.skill_name, + invocation_mode: si.invocation_mode, + triggered: (si.triggered as number) === 1, + confidence: si.confidence, + tool_name: si.tool_name ?? undefined, + matched_prompt_id: si.matched_prompt_id ?? undefined, + agent_type: si.agent_type ?? undefined, + }); + } + + // Execution facts + const facts = db + .query( + `SELECT id, session_id, occurred_at, prompt_id, tool_calls_json, total_tool_calls, + assistant_turns, errors_encountered, input_tokens, output_tokens, + duration_ms, completion_status, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref + FROM execution_facts ORDER BY occurred_at`, + ) + .all() as Array>; + for (const ef of facts) { + const sessionEnvelope = sessions.find((s) => s.session_id === ef.session_id); + records.push({ + record_kind: "execution_fact", + schema_version: ef.schema_version ?? sessionEnvelope?.schema_version ?? undefined, + normalizer_version: ef.normalizer_version ?? sessionEnvelope?.normalizer_version ?? undefined, + normalized_at: ef.normalized_at ?? sessionEnvelope?.normalized_at ?? undefined, + platform: ef.platform ?? sessionEnvelope?.platform ?? undefined, + capture_mode: ef.capture_mode ?? sessionEnvelope?.capture_mode ?? undefined, + raw_source_ref: safeParseJson(ef.raw_source_ref as string | null) + ?? safeParseJson(sessionEnvelope?.raw_source_ref as string | null) + ?? undefined, + source_session_kind: sessionEnvelope?.source_session_kind ?? undefined, + session_id: ef.session_id, + occurred_at: ef.occurred_at, + prompt_id: ef.prompt_id ?? undefined, + tool_calls_json: safeParseJson(ef.tool_calls_json as string | null) ?? {}, + total_tool_calls: ef.total_tool_calls, + assistant_turns: ef.assistant_turns, + errors_encountered: ef.errors_encountered, + input_tokens: ef.input_tokens ?? undefined, + output_tokens: ef.output_tokens ?? undefined, + duration_ms: ef.duration_ms ?? undefined, + completion_status: ef.completion_status ?? undefined, + }); + } + + return records; +} + // -- Alpha upload query helpers ----------------------------------------------- /** diff --git a/cli/selftune/localdb/schema.ts b/cli/selftune/localdb/schema.ts index 323a3b63..8ea6c5c4 100644 --- a/cli/selftune/localdb/schema.ts +++ b/cli/selftune/localdb/schema.ts @@ -21,7 +21,10 @@ CREATE TABLE IF NOT EXISTS sessions ( repo_remote TEXT, branch TEXT, schema_version TEXT, - normalized_at TEXT + normalized_at TEXT, + normalizer_version TEXT, + capture_mode TEXT, + raw_source_ref TEXT )`; export const CREATE_PROMPTS = ` @@ -33,6 +36,12 @@ CREATE TABLE IF NOT EXISTS prompts ( is_actionable INTEGER, prompt_index INTEGER, prompt_text TEXT, + schema_version TEXT, + platform TEXT, + normalized_at TEXT, + normalizer_version TEXT, + capture_mode TEXT, + raw_source_ref TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) )`; @@ -52,6 +61,12 @@ CREATE TABLE IF NOT EXISTS skill_invocations ( skill_path TEXT, skill_scope TEXT, source TEXT, + schema_version TEXT, + platform TEXT, + normalized_at TEXT, + normalizer_version TEXT, + capture_mode TEXT, + raw_source_ref TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) )`; @@ -69,6 +84,12 @@ CREATE TABLE IF NOT EXISTS execution_facts ( output_tokens INTEGER, duration_ms INTEGER, completion_status TEXT, + schema_version TEXT, + platform TEXT, + normalized_at TEXT, + normalizer_version TEXT, + capture_mode TEXT, + raw_source_ref TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) )`; @@ -283,6 +304,28 @@ export const MIGRATIONS = [ `ALTER TABLE skill_invocations ADD COLUMN source TEXT`, // Track how many iteration loops each evolution run used `ALTER TABLE evolution_audit ADD COLUMN iterations_used INTEGER`, + // Canonical contract fields for upload staging (sessions already has schema_version, platform, normalized_at) + `ALTER TABLE sessions ADD COLUMN normalizer_version TEXT`, + `ALTER TABLE sessions ADD COLUMN capture_mode TEXT`, + `ALTER TABLE sessions ADD COLUMN raw_source_ref TEXT`, + `ALTER TABLE prompts ADD COLUMN schema_version TEXT`, + `ALTER TABLE prompts ADD COLUMN platform TEXT`, + `ALTER TABLE prompts ADD COLUMN normalized_at TEXT`, + `ALTER TABLE prompts ADD COLUMN normalizer_version TEXT`, + `ALTER TABLE prompts ADD COLUMN capture_mode TEXT`, + `ALTER TABLE prompts ADD COLUMN raw_source_ref TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN schema_version TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN platform TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN normalized_at TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN normalizer_version TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN capture_mode TEXT`, + `ALTER TABLE skill_invocations ADD COLUMN raw_source_ref TEXT`, + `ALTER TABLE execution_facts ADD COLUMN schema_version TEXT`, + `ALTER TABLE execution_facts ADD COLUMN platform TEXT`, + `ALTER TABLE execution_facts ADD COLUMN normalized_at TEXT`, + `ALTER TABLE execution_facts ADD COLUMN normalizer_version TEXT`, + `ALTER TABLE execution_facts ADD COLUMN capture_mode TEXT`, + `ALTER TABLE execution_facts ADD COLUMN raw_source_ref TEXT`, ]; /** Indexes that depend on migration columns — must run AFTER MIGRATIONS. */ diff --git a/cli/selftune/monitoring/watch.ts b/cli/selftune/monitoring/watch.ts index 241e478e..40e179b3 100644 --- a/cli/selftune/monitoring/watch.ts +++ b/cli/selftune/monitoring/watch.ts @@ -228,6 +228,7 @@ export async function watch(options: WatchOptions): Promise { skillRecords = querySkillUsageRecords(db) as SkillUsageRecord[]; queryRecords = queryQueryLog(db) as QueryLogRecord[]; } else { + // Intentional JSONL fallback: custom log path overrides bypass SQLite reads telemetry = readJsonl(_telemetryLogPath); skillRecords = readJsonl(_skillLogPath); queryRecords = readJsonl(_queryLogPath); diff --git a/cli/selftune/orchestrate.ts b/cli/selftune/orchestrate.ts index bae01e24..a3230e7f 100644 --- a/cli/selftune/orchestrate.ts +++ b/cli/selftune/orchestrate.ts @@ -16,12 +16,12 @@ import { parseArgs } from "node:util"; import { readAlphaIdentity } from "./alpha-identity.js"; import type { UploadCycleSummary } from "./alpha-upload/index.js"; -import { ORCHESTRATE_LOCK, SELFTUNE_CONFIG_PATH, SIGNAL_LOG } from "./constants.js"; +import { ORCHESTRATE_LOCK, SELFTUNE_CONFIG_PATH } from "./constants.js"; import type { OrchestrateRunReport, OrchestrateRunSkillAction } from "./dashboard-contract.js"; import type { EvolveResult } from "./evolution/evolve.js"; import { readGradingResultsForSkill } from "./grading/results.js"; import { getDb } from "./localdb/db.js"; -import { writeOrchestrateRunToDb } from "./localdb/direct-write.js"; +import { updateSignalConsumed, writeOrchestrateRunToDb } from "./localdb/direct-write.js"; import { queryEvolutionAudit, queryImprovementSignals, @@ -42,7 +42,6 @@ import type { SessionTelemetryRecord, SkillUsageRecord, } from "./types.js"; -import { readJsonl } from "./utils/jsonl.js"; import { detectAgent } from "./utils/llm-call.js"; import { getSelftuneVersion, readConfiguredAgentType } from "./utils/selftune-meta.js"; import { @@ -126,39 +125,12 @@ export function groupSignalsBySkill(signals: ImprovementSignalRecord[]): Map `${s.timestamp}|${s.session_id}`)); - - const allRecords = readJsonl(signalLogPath); - const now = new Date().toISOString(); - const updated = allRecords.map((record) => { - const key = `${record.timestamp}|${record.session_id}`; - if (pendingKeys.has(key) && !record.consumed) { - return { - ...record, - consumed: true, - consumed_at: now, - consumed_by_run: runId, - }; - } - return record; - }); - - // Re-read to capture any signals appended between our read and write - const freshRecords = readJsonl(signalLogPath); - const existingKeys = new Set(updated.map((r) => `${r.timestamp}|${r.session_id}`)); - const newlyAppended = freshRecords.filter( - (r) => !existingKeys.has(`${r.timestamp}|${r.session_id}`), - ); - const merged = [...updated, ...newlyAppended]; - - writeFileSync(signalLogPath, `${merged.map((r) => JSON.stringify(r)).join("\n")}\n`); + for (const signal of signals) { + updateSignalConsumed(signal.session_id, signal.query, signal.signal_type, runId); + } } catch { // Silent on errors } diff --git a/cli/selftune/repair/skill-usage.ts b/cli/selftune/repair/skill-usage.ts index c8c34ccd..8d3dfbb1 100644 --- a/cli/selftune/repair/skill-usage.ts +++ b/cli/selftune/repair/skill-usage.ts @@ -511,14 +511,17 @@ Options: since, ); const rolloutPaths = findRolloutFiles(values["codex-home"] ?? DEFAULT_CODEX_HOME, since); + // SQLite-first: default paths read from SQLite; JSONL only for custom --skill-log overrides let rawSkillRecords: SkillUsageRecord[]; let queryRecords: QueryLogRecord[]; - try { + const skillLogPath = values["skill-log"] ?? SKILL_LOG; + if (skillLogPath === SKILL_LOG) { const db = getDb(); rawSkillRecords = querySkillUsageRecords(db) as SkillUsageRecord[]; queryRecords = queryQueryLog(db) as QueryLogRecord[]; - } catch { - rawSkillRecords = readJsonl(values["skill-log"] ?? SKILL_LOG); + } else { + // test/custom-path fallback + rawSkillRecords = readJsonl(skillLogPath); queryRecords = readJsonl(QUERY_LOG); } const { repairedRecords, repairedSessionIds } = rebuildSkillUsageFromTranscripts( diff --git a/cli/selftune/sync.ts b/cli/selftune/sync.ts index 774225ac..b4336bce 100644 --- a/cli/selftune/sync.ts +++ b/cli/selftune/sync.ts @@ -367,6 +367,7 @@ function rebuildSkillUsageOverlay( rawSkillRecords = readJsonl(options.skillLogPath); } } else { + // Intentional JSONL fallback: custom --skill-log path overrides SQLite reads rawSkillRecords = readJsonl(options.skillLogPath); } const { repairedRecords, repairedSessionIds } = rebuildSkillUsageFromTranscripts( diff --git a/docs/design-docs/live-dashboard-sse.md b/docs/design-docs/live-dashboard-sse.md index 2b30f33c..36f44a95 100644 --- a/docs/design-docs/live-dashboard-sse.md +++ b/docs/design-docs/live-dashboard-sse.md @@ -4,11 +4,10 @@ ## Status -This doc describes the intended end-state for live dashboard freshness, not the fully shipped runtime. +Shipped. The dashboard uses SQLite WAL-based invalidation as the sole live update signal. -- Current runtime: SSE exists, but invalidation still watches selected JSONL logs in `cli/selftune/dashboard-server.ts`. -- Pending cutover: SQLite WAL should become the only live invalidation signal. -- Canonical tracking plan: `docs/exec-plans/active/dashboard-data-integrity-recovery.md` +- `fs.watchFile()` monitors `~/.selftune/selftune.db-wal` with 500ms stat polling. +- JSONL file watchers have been removed from `cli/selftune/dashboard-server.ts`. ## Problem @@ -49,15 +48,13 @@ sequenceDiagram ### SQLite WAL Watcher -End-state design: `fs.watchFile()` monitors the SQLite WAL file (`~/.selftune/selftune.db-wal`) with 500ms polling. When hooks write directly to SQLite, the WAL file's modification time or size changes, triggering the watcher. - -Current runtime note: the old JSONL file watchers have not been fully removed yet. The shipped dashboard still warns when running in legacy JSONL watcher mode. +`fs.watchFile()` monitors the SQLite WAL file (`~/.selftune/selftune.db-wal`) with 500ms stat polling. When hooks write directly to SQLite, the WAL file's modification time or size changes, triggering the watcher. A 500ms debounce timer coalesces rapid writes (e.g., a hook appending multiple records in sequence) into a single broadcast cycle. ### No Separate Materialization Step -Target design: because hooks now write directly to SQLite, there is no separate materialization step in the hot path. The data is already in the database when the WAL watcher fires. The server simply broadcasts the SSE event and the next API query reads fresh data directly from SQLite. +Because hooks write directly to SQLite, there is no separate materialization step in the hot path. The data is already in the database when the WAL watcher fires. The server simply broadcasts the SSE event and the next API query reads fresh data directly from SQLite. ### Fan-Out @@ -65,7 +62,7 @@ Target design: because hooks now write directly to SQLite, there is no separate ### Cleanup -On shutdown (`SIGINT`/`SIGTERM`), the WAL file watcher is closed, SSE client controllers are closed, and debounce timers are cleared before the server stops. +On shutdown (`SIGINT`/`SIGTERM`), the WAL file watcher is removed via `fs.unwatchFile()`, SSE client controllers are closed, and debounce timers are cleared before the server stops. ## Client Side @@ -121,9 +118,8 @@ After the WAL cutover lands, new data should appear in the dashboard within ~1 s **Why keep polling?** SSE connections can drop. `EventSource` reconnects automatically, but during the reconnect window (up to 3s by default) no updates arrive. The 60s polling fallback ensures the dashboard never goes completely stale. -## Current Limitations +## Limitations -- The runtime is still using legacy JSONL watcher invalidation in some paths, so the WAL-only freshness model described above is not yet the sole shipped behavior. -- `fs.watchFile()` uses stat polling (500ms interval), so even after the WAL cutover there is an inherent latency floor compared to event-driven watchers. +- `fs.watchFile()` uses stat polling (500ms interval), so there is an inherent latency floor compared to event-driven watchers. - On network filesystems, stat polling may be slower or return stale metadata. - The debounce means writes within the same 500ms window are coalesced; the dashboard won't show intermediate states within a burst. diff --git a/docs/design-docs/sqlite-first-migration.md b/docs/design-docs/sqlite-first-migration.md index 9b9dabfd..11a807ce 100644 --- a/docs/design-docs/sqlite-first-migration.md +++ b/docs/design-docs/sqlite-first-migration.md @@ -4,11 +4,11 @@ ## Status -Most SQLite-first read-path work has landed, but this doc currently overstates the freshness cutover. +Phase 2 is complete. Phase 3 is in progress. -- Landed: hooks/sync write to SQLite, dashboard/status/report reads are primarily SQLite-backed. -- Still open: SSE invalidation is not yet WAL-only in the shipped runtime. -- Treat this document as migration design plus progress notes, not as a perfect description of current live freshness behavior. +- Phase 1 (dual-write): Shipped. Hooks write to both SQLite and JSONL. +- Phase 2 (cut over reads): Shipped. Dashboard reads SQLite, SSE invalidation uses WAL watcher. +- Phase 3 (drop JSONL writes): In progress. Remaining JSONL reads being eliminated from hot paths. ## Problem @@ -24,7 +24,7 @@ JSONL-as-source-of-truth caused: **Phase 1: Dual-Write** — Hooks INSERT into SQLite alongside JSONL appends via `localdb/direct-write.ts`. Zero risk: additive only, fully reversible. -**Phase 2: Cut Over Reads** — Dashboard reads SQLite directly. Materializer is removed from the hot path for normal reads (runs once on startup for historical backfill). WAL-based SSE invalidation is the target end-state, but the shipped runtime still carries legacy JSONL watcher invalidation in `dashboard-server.ts`. +**Phase 2: Cut Over Reads** (Shipped) — Dashboard reads SQLite directly. Materializer runs once on startup for historical backfill. WAL-based SSE invalidation is live — `fs.watchFile()` monitors the SQLite WAL file for changes and triggers SSE broadcasts. **Phase 3: Drop JSONL Writes** — Hooks stop appending JSONL. SQLite is the sole write target. A new `selftune export` command generates JSONL from SQLite on demand for portability. @@ -36,7 +36,7 @@ Data flow (before): Hook → JSONL append → [15s wait] → Materializer reads JSONL → SQLite → Dashboard ``` -Target data flow (after full freshness cutover): +Data flow (after Phase 2 — shipped): ``` Hook → SQLite INSERT (via direct-write.ts) → WAL watcher → SSE broadcast → Dashboard @@ -83,13 +83,12 @@ Hook → SQLite INSERT (via direct-write.ts) → WAL watcher → SSE broadcast |--------|--------|-------| | Dashboard load (first call) | 9.5s | 86ms | | Dashboard load (subsequent) | ~2s (TTL hit) | 15ms | -| Data latency (hook → dashboard) | 15–30s | target: <1s after WAL-only SSE cutover | +| Data latency (hook → dashboard) | 15–30s | <1s (WAL-only SSE shipped) | | Schema change propagation | 7 files | 4 files | | Test delta | baseline | +2 passing, -2 failures | ## Limitations -- The WAL-only SSE cutover is not yet complete — legacy JSONL watcher invalidation still exists in the current runtime - Phase 3 (drop JSONL writes) is not yet complete — dual-write is still active - Historical data prior to Phase 1 requires a one-time materializer backfill on first startup - `selftune export --since DATE` is supported for date-range filtering; per-skill filtering is not yet implemented diff --git a/docs/exec-plans/active/dashboard-data-integrity-recovery.md b/docs/exec-plans/completed/dashboard-data-integrity-recovery.md similarity index 100% rename from docs/exec-plans/active/dashboard-data-integrity-recovery.md rename to docs/exec-plans/completed/dashboard-data-integrity-recovery.md diff --git a/docs/exec-plans/active/dashboard-signal-integration.md b/docs/exec-plans/completed/dashboard-signal-integration.md similarity index 100% rename from docs/exec-plans/active/dashboard-signal-integration.md rename to docs/exec-plans/completed/dashboard-signal-integration.md diff --git a/docs/exec-plans/active/local-sqlite-materialization.md b/docs/exec-plans/completed/local-sqlite-materialization.md similarity index 100% rename from docs/exec-plans/active/local-sqlite-materialization.md rename to docs/exec-plans/completed/local-sqlite-materialization.md diff --git a/docs/exec-plans/active/output-quality-loop-prereqs.md b/docs/exec-plans/completed/output-quality-loop-prereqs.md similarity index 100% rename from docs/exec-plans/active/output-quality-loop-prereqs.md rename to docs/exec-plans/completed/output-quality-loop-prereqs.md diff --git a/docs/exec-plans/active/telemetry-normalization.md b/docs/exec-plans/completed/telemetry-normalization.md similarity index 100% rename from docs/exec-plans/active/telemetry-normalization.md rename to docs/exec-plans/completed/telemetry-normalization.md diff --git a/docs/exec-plans/active/advanced-skill-patterns-adoption.md b/docs/exec-plans/deferred/advanced-skill-patterns-adoption.md similarity index 100% rename from docs/exec-plans/active/advanced-skill-patterns-adoption.md rename to docs/exec-plans/deferred/advanced-skill-patterns-adoption.md diff --git a/docs/exec-plans/active/cloud-auth-unification-for-alpha.md b/docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md similarity index 100% rename from docs/exec-plans/active/cloud-auth-unification-for-alpha.md rename to docs/exec-plans/deferred/cloud-auth-unification-for-alpha.md diff --git a/docs/exec-plans/active/grader-prompt-evals.md b/docs/exec-plans/deferred/grader-prompt-evals.md similarity index 100% rename from docs/exec-plans/active/grader-prompt-evals.md rename to docs/exec-plans/deferred/grader-prompt-evals.md diff --git a/docs/exec-plans/active/mcp-tool-descriptions.md b/docs/exec-plans/deferred/mcp-tool-descriptions.md similarity index 100% rename from docs/exec-plans/active/mcp-tool-descriptions.md rename to docs/exec-plans/deferred/mcp-tool-descriptions.md diff --git a/docs/exec-plans/active/multi-agent-sandbox.md b/docs/exec-plans/deferred/multi-agent-sandbox.md similarity index 100% rename from docs/exec-plans/active/multi-agent-sandbox.md rename to docs/exec-plans/deferred/multi-agent-sandbox.md diff --git a/docs/exec-plans/active/phase-d-marginal-case-review-spike.md b/docs/exec-plans/deferred/phase-d-marginal-case-review-spike.md similarity index 100% rename from docs/exec-plans/active/phase-d-marginal-case-review-spike.md rename to docs/exec-plans/deferred/phase-d-marginal-case-review-spike.md diff --git a/docs/exec-plans/active/user-owned-session-data-and-org-visible-outcomes.md b/docs/exec-plans/deferred/user-owned-session-data-and-org-visible-outcomes.md similarity index 100% rename from docs/exec-plans/active/user-owned-session-data-and-org-visible-outcomes.md rename to docs/exec-plans/deferred/user-owned-session-data-and-org-visible-outcomes.md diff --git a/skill/Workflows/Dashboard.md b/skill/Workflows/Dashboard.md index 60ef4fa9..a974ebf1 100644 --- a/skill/Workflows/Dashboard.md +++ b/skill/Workflows/Dashboard.md @@ -11,14 +11,11 @@ selftune dashboard ``` Starts a Bun HTTP server with a React SPA dashboard and opens it in the -default browser. The dashboard reads SQLite directly, but the current -live-update invalidation path still watches JSONL logs and pushes -updates via Server-Sent Events (SSE). That means the dashboard usually -refreshes quickly, but SQLite-only writes can still lag until the WAL -cutover lands. TanStack Query polling (60s) acts as a fallback. Action -buttons trigger selftune commands directly from the dashboard. Use -`selftune export` to generate JSONL from SQLite for debugging or -offline analysis. +default browser. The dashboard reads SQLite directly and uses WAL-based +invalidation to push live updates via Server-Sent Events (SSE). +TanStack Query polling (60s) acts as a fallback. Action buttons trigger +selftune commands directly from the dashboard. Use `selftune export` to +generate JSONL from SQLite for debugging or offline analysis. ## Options diff --git a/skill/Workflows/Doctor.md b/skill/Workflows/Doctor.md index 2d554eac..6fe6c281 100644 --- a/skill/Workflows/Doctor.md +++ b/skill/Workflows/Doctor.md @@ -40,14 +40,14 @@ None. Doctor runs all checks unconditionally. }, { "name": "dashboard_freshness_mode", - "status": "warn", - "message": "Dashboard still uses legacy JSONL watcher invalidation" + "status": "pass", + "message": "Dashboard reads SQLite and watches WAL for live updates" } ], "summary": { - "pass": 8, + "pass": 9, "fail": 1, - "warn": 1, + "warn": 0, "total": 10 }, "healthy": false diff --git a/skill/references/logs.md b/skill/references/logs.md index 7d6c412f..cf468e4e 100644 --- a/skill/references/logs.md +++ b/skill/references/logs.md @@ -4,6 +4,12 @@ selftune writes raw legacy logs plus a canonical event log. This reference describes each format in detail for the skill to use when parsing sessions, audit trails, and cloud-ingest exports. +> **Note:** JSONL files are now backup/recovery only. SQLite (`~/.selftune/selftune.db`) +> is the sole operational store for all runtime reads. JSONL writes are retained for +> append-only durability, but all dashboard queries, hook reads, grading, monitoring, +> and upload staging read from SQLite. JSONL reads only occur when custom log paths +> are provided (e.g., `--telemetry-log`, `--skill-log`) for test isolation. + --- ## ~/.claude/session_telemetry_log.jsonl diff --git a/tests/signal-orchestrate.test.ts b/tests/signal-orchestrate.test.ts index 091635a8..f836e70a 100644 --- a/tests/signal-orchestrate.test.ts +++ b/tests/signal-orchestrate.test.ts @@ -209,8 +209,10 @@ describe("markSignalsConsumed", () => { }); test("marks matching signals as consumed", () => { - tempDir = makeTempDir(); - const signalPath = join(tempDir, "signals.jsonl"); + // Seed signals into SQLite via direct-write + const { writeImprovementSignalToDb } = require("../cli/selftune/localdb/direct-write.js"); + const { queryImprovementSignals } = require("../cli/selftune/localdb/queries.js"); + const { getDb } = require("../cli/selftune/localdb/db.js"); const signals = [ makeSignal({ timestamp: "2025-01-01T00:00:00Z", session_id: "s1", mentioned_skill: "A" }), @@ -224,52 +226,41 @@ describe("markSignalsConsumed", () => { }), ]; - writeFileSync(signalPath, `${signals.map((s) => JSON.stringify(s)).join("\n")}\n`); + for (const s of signals) { + writeImprovementSignalToDb(s); + } // Only pass the unconsumed signals as pending const pendingSignals = signals.filter((s) => !s.consumed); - markSignalsConsumed(pendingSignals, "run_123", signalPath); + markSignalsConsumed(pendingSignals, "run_123"); - const updated = readJsonl(signalPath); + const db = getDb(); + const updated = db + .query("SELECT * FROM improvement_signals WHERE session_id IN ('s1','s2','s3') ORDER BY timestamp ASC") + .all(); expect(updated).toHaveLength(3); // First two should be consumed - expect(updated[0].consumed).toBe(true); + expect(updated[0].consumed).toBe(1); expect(updated[0].consumed_by_run).toBe("run_123"); expect(updated[0].consumed_at).toBeDefined(); - expect(updated[1].consumed).toBe(true); + expect(updated[1].consumed).toBe(1); expect(updated[1].consumed_by_run).toBe("run_123"); // Third was already consumed, should retain original values - expect(updated[2].consumed).toBe(true); + expect(updated[2].consumed).toBe(1); expect(updated[2].consumed_by_run).toBe("old_run"); }); - test("handles missing signal log gracefully", () => { - tempDir = makeTempDir(); - const signalPath = join(tempDir, "nonexistent.jsonl"); - const pendingSignals = [ - makeSignal({ timestamp: "2025-01-01T00:00:00Z", session_id: "s1", mentioned_skill: "A" }), - ]; - - // Should not throw even with non-empty pending list and missing file - expect(() => markSignalsConsumed(pendingSignals, "run_123", signalPath)).not.toThrow(); - expect(existsSync(signalPath)).toBe(false); - }); - test("handles empty pending signals", () => { - tempDir = makeTempDir(); - const signalPath = join(tempDir, "signals.jsonl"); - - const signals = [makeSignal({ timestamp: "2025-01-01T00:00:00Z", session_id: "s1" })]; - writeFileSync(signalPath, `${signals.map((s) => JSON.stringify(s)).join("\n")}\n`); - - markSignalsConsumed([], "run_123", signalPath); + // Should not throw with empty list + expect(() => markSignalsConsumed([], "run_123")).not.toThrow(); + }); - const updated = readJsonl(signalPath); - expect(updated).toHaveLength(1); - expect(updated[0].consumed).toBe(false); + test("handles empty pending signals (no-op)", () => { + // markSignalsConsumed with empty array should be a no-op + expect(() => markSignalsConsumed([], "run_456")).not.toThrow(); }); }); diff --git a/tests/trust-floor/health.test.ts b/tests/trust-floor/health.test.ts index 4719223c..9c5ccbc4 100644 --- a/tests/trust-floor/health.test.ts +++ b/tests/trust-floor/health.test.ts @@ -73,7 +73,7 @@ describe("/api/health runtime identity", () => { expect(typeof body.log_dir).toBe("string"); expect(typeof body.config_dir).toBe("string"); - expect(["jsonl", "none"]).toContain(body.watcher_mode); + expect(["wal", "jsonl", "none"]).toContain(body.watcher_mode); expect(body.process_mode).toBe("test"); expect(body.host).toBe("127.0.0.1"); From a13f12d5af86046f6e3605fb00b99a1c25ee00cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 20 Mar 2026 14:48:06 +0000 Subject: [PATCH 04/12] chore: bump cli version to v0.2.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 63639ce8..b29bd2a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.2.8", + "version": "0.2.9", "description": "Self-improving skills CLI for AI agents", "type": "module", "license": "MIT", From a4f880e269f6abddec7da9b7fffc91596e3f5299 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:14:13 +0300 Subject: [PATCH 05/12] fix: address CodeRabbit review feedback on SQLite cutover PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix stale-evolution rule using oldest instead of newest audit entry (queryEvolutionAudit returns DESC order, so use [0] not [length-1]) - Fix agent guidance emitting placeholder email in next_command - Add "wal" to runtime-footer type guard in dashboard SPA - Pass specific listener to unwatchFile() to avoid removing all watchers - Preserve existing alpha identity metadata on re-auth without flags - Let alpha mutations bypass existing-config fast path in runInit() - Replace O(n²) sessions.find() with Map lookup in staging query - Log failed signal consumed writes in orchestrate - Fix Dashboard.md SSE section still referencing JSONL watchers - Remove non-null assertions in staging test - Convert require() to ESM imports in signal-orchestrate test - Remove duplicate empty-signals test case Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/local-dashboard/src/components/runtime-footer.tsx | 2 +- cli/selftune/activation-rules.ts | 2 +- cli/selftune/agent-guidance.ts | 9 ++++----- cli/selftune/dashboard-server.ts | 2 +- cli/selftune/init.ts | 10 ++++++---- cli/selftune/localdb/queries.ts | 7 ++++--- cli/selftune/orchestrate.ts | 7 ++++++- skill/Workflows/Dashboard.md | 8 +++----- tests/alpha-upload/staging.test.ts | 6 ++++-- tests/signal-orchestrate.test.ts | 10 +++------- 10 files changed, 33 insertions(+), 30 deletions(-) diff --git a/apps/local-dashboard/src/components/runtime-footer.tsx b/apps/local-dashboard/src/components/runtime-footer.tsx index 5f37a4f5..69eeb4b5 100644 --- a/apps/local-dashboard/src/components/runtime-footer.tsx +++ b/apps/local-dashboard/src/components/runtime-footer.tsx @@ -9,7 +9,7 @@ function isHealthResponse(value: unknown): value is HealthResponse { typeof record.git_sha === "string" && typeof record.db_path === "string" && typeof record.process_mode === "string" && - (record.watcher_mode === "jsonl" || record.watcher_mode === "none") + (record.watcher_mode === "wal" || record.watcher_mode === "jsonl" || record.watcher_mode === "none") ) } diff --git a/cli/selftune/activation-rules.ts b/cli/selftune/activation-rules.ts index ed81bd41..36b00770 100644 --- a/cli/selftune/activation-rules.ts +++ b/cli/selftune/activation-rules.ts @@ -132,7 +132,7 @@ const staleEvolution: ActivationRule = { : null; } - const lastEntry = auditEntries[auditEntries.length - 1]; + const lastEntry = auditEntries[0]; // queryEvolutionAudit returns DESC order const lastTimestamp = new Date(lastEntry.timestamp).getTime(); const ageMs = Date.now() - lastTimestamp; diff --git a/cli/selftune/agent-guidance.ts b/cli/selftune/agent-guidance.ts index 2387d24f..e28e4a7e 100644 --- a/cli/selftune/agent-guidance.ts +++ b/cli/selftune/agent-guidance.ts @@ -1,15 +1,14 @@ import { getAlphaLinkState } from "./alpha-identity.js"; import type { AgentCommandGuidance, AlphaIdentity, AlphaLinkState } from "./types.js"; -function emailArg(email?: string): string { - return email?.trim() ? email : ""; -} - function buildAlphaInitCommand(options?: { email?: string; force?: boolean; }): string { - const parts = ["selftune", "init", "--alpha", "--alpha-email", emailArg(options?.email)]; + const parts = ["selftune", "init", "--alpha"]; + if (options?.email?.trim()) { + parts.push("--alpha-email", options.email); + } if (options?.force) { parts.push("--force"); } diff --git a/cli/selftune/dashboard-server.ts b/cli/selftune/dashboard-server.ts index 0944ea92..3761090a 100644 --- a/cli/selftune/dashboard-server.ts +++ b/cli/selftune/dashboard-server.ts @@ -533,7 +533,7 @@ export async function startDashboardServer( // Graceful shutdown const shutdownHandler = () => { - unwatchFile(walPath); + unwatchFile(walPath, onWALChange); clearInterval(sseKeepaliveTimer); for (const c of sseClients) { try { diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index 5c95bdd5..49fc686f 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -469,8 +469,10 @@ export interface InitOptions { export async function runInit(opts: InitOptions): Promise { const { configDir, configPath, force } = opts; - // If config exists and no --force, return existing - if (!force && existsSync(configPath)) { + // If config exists and no --force (and no alpha mutation), return existing + const hasAlphaMutation = + opts.alpha || opts.noAlpha || opts.alphaEmail !== undefined || opts.alphaName !== undefined; + if (!force && !hasAlphaMutation && existsSync(configPath)) { const raw = readFileSync(configPath, "utf-8"); try { return JSON.parse(raw) as SelftuneConfig; @@ -548,8 +550,8 @@ export async function runInit(opts: InitOptions): Promise { user_id: existingAlphaBeforeOverwrite?.user_id ?? generateUserId(), cloud_user_id: result.cloud_user_id, cloud_org_id: result.org_id, - email: opts.alphaEmail, - display_name: opts.alphaName, + email: opts.alphaEmail ?? existingAlphaBeforeOverwrite?.email, + display_name: opts.alphaName ?? existingAlphaBeforeOverwrite?.display_name, consent_timestamp: new Date().toISOString(), api_key: result.api_key, }; diff --git a/cli/selftune/localdb/queries.ts b/cli/selftune/localdb/queries.ts index 4b6f7aa2..7975a019 100644 --- a/cli/selftune/localdb/queries.ts +++ b/cli/selftune/localdb/queries.ts @@ -600,6 +600,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record>; + const sessionById = new Map(sessions.map((s) => [s.session_id as string, s])); for (const s of sessions) { records.push({ record_kind: "session", @@ -632,7 +633,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record>; for (const p of prompts) { // Fall back to session-level envelope fields if prompt doesn't have its own - const sessionEnvelope = sessions.find((s) => s.session_id === p.session_id); + const sessionEnvelope = sessionById.get(p.session_id as string); records.push({ record_kind: "prompt", schema_version: p.schema_version ?? sessionEnvelope?.schema_version ?? undefined, @@ -664,7 +665,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record>; for (const si of invocations) { - const sessionEnvelope = sessions.find((s) => s.session_id === si.session_id); + const sessionEnvelope = sessionById.get(si.session_id as string); records.push({ record_kind: "skill_invocation", schema_version: si.schema_version ?? sessionEnvelope?.schema_version ?? undefined, @@ -700,7 +701,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record>; for (const ef of facts) { - const sessionEnvelope = sessions.find((s) => s.session_id === ef.session_id); + const sessionEnvelope = sessionById.get(ef.session_id as string); records.push({ record_kind: "execution_fact", schema_version: ef.schema_version ?? sessionEnvelope?.schema_version ?? undefined, diff --git a/cli/selftune/orchestrate.ts b/cli/selftune/orchestrate.ts index a3230e7f..63830ce4 100644 --- a/cli/selftune/orchestrate.ts +++ b/cli/selftune/orchestrate.ts @@ -129,7 +129,12 @@ export function markSignalsConsumed( try { if (signals.length === 0) return; for (const signal of signals) { - updateSignalConsumed(signal.session_id, signal.query, signal.signal_type, runId); + const ok = updateSignalConsumed(signal.session_id, signal.query, signal.signal_type, runId); + if (!ok) { + console.error( + `[orchestrate] failed to mark signal consumed: session_id=${signal.session_id}, signal_type=${signal.signal_type}`, + ); + } } } catch { // Silent on errors diff --git a/skill/Workflows/Dashboard.md b/skill/Workflows/Dashboard.md index a974ebf1..dd1d9016 100644 --- a/skill/Workflows/Dashboard.md +++ b/skill/Workflows/Dashboard.md @@ -53,11 +53,9 @@ override. ### Live Updates (SSE) The dashboard connects to `/api/v2/events` via Server-Sent Events. -When watched JSONL log files change on disk, the server broadcasts an -`update` event. The SPA invalidates all cached queries, triggering -immediate refetches. New data usually appears quickly, but the runtime -footer and Status page will warn when the server is still in this -legacy JSONL watcher mode. +The server watches the SQLite WAL file for changes and broadcasts an +`update` event when new data is written. The SPA invalidates all cached +queries, triggering immediate refetches (~1s latency). TanStack Query polling (60s) acts as a fallback safety net in case the SSE connection drops. Data also refreshes on window focus. diff --git a/tests/alpha-upload/staging.test.ts b/tests/alpha-upload/staging.test.ts index ff6f32be..02e30c90 100644 --- a/tests/alpha-upload/staging.test.ts +++ b/tests/alpha-upload/staging.test.ts @@ -708,7 +708,8 @@ describe("buildV2PushPayload (staging-based)", () => { expect(result).not.toBeNull(); expect(result).toBeDefined(); - const p = result!.payload; + if (!result) throw new Error("expected staged payload"); + const p = result.payload; // Envelope fields expect(p.schema_version).toBe("2.0"); expect(typeof p.client_version).toBe("string"); @@ -732,7 +733,8 @@ describe("buildV2PushPayload (staging-based)", () => { expect(sess.session_id).toBe("sess-v"); // Spot-check evolution evidence - const ev = c.evolution_evidence![0]; + const ev = c.evolution_evidence?.[0]; + expect(ev).toBeDefined(); expect(ev.skill_name).toBe("selftune"); expect(ev.proposal_id).toBe("prop-v"); }); diff --git a/tests/signal-orchestrate.test.ts b/tests/signal-orchestrate.test.ts index f836e70a..0aee5980 100644 --- a/tests/signal-orchestrate.test.ts +++ b/tests/signal-orchestrate.test.ts @@ -12,6 +12,9 @@ import { import type { SkillStatus } from "../cli/selftune/status.js"; import type { ImprovementSignalRecord, MonitoringSnapshot } from "../cli/selftune/types.js"; import { readJsonl } from "../cli/selftune/utils/jsonl.js"; +import { writeImprovementSignalToDb } from "../cli/selftune/localdb/direct-write.js"; +import { queryImprovementSignals } from "../cli/selftune/localdb/queries.js"; +import { getDb } from "../cli/selftune/localdb/db.js"; // --------------------------------------------------------------------------- // Helpers @@ -210,9 +213,6 @@ describe("markSignalsConsumed", () => { test("marks matching signals as consumed", () => { // Seed signals into SQLite via direct-write - const { writeImprovementSignalToDb } = require("../cli/selftune/localdb/direct-write.js"); - const { queryImprovementSignals } = require("../cli/selftune/localdb/queries.js"); - const { getDb } = require("../cli/selftune/localdb/db.js"); const signals = [ makeSignal({ timestamp: "2025-01-01T00:00:00Z", session_id: "s1", mentioned_skill: "A" }), @@ -258,10 +258,6 @@ describe("markSignalsConsumed", () => { expect(() => markSignalsConsumed([], "run_123")).not.toThrow(); }); - test("handles empty pending signals (no-op)", () => { - // markSignalsConsumed with empty array should be a no-op - expect(() => markSignalsConsumed([], "run_456")).not.toThrow(); - }); }); // --------------------------------------------------------------------------- From 6054c32d35f3da8ef5cbe673f7c84a7f5b200928 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:18:55 +0300 Subject: [PATCH 06/12] fix: resolve lint, formatting, and test assertion failures in CI - Remove unused readJsonl and queryImprovementSignals imports - Sort imports alphabetically per biome organizeImports rule - Format function signatures and long lines per biome formatter - Use template literal for WAL path concatenation - Update formatAlphaStatus tests for new guidance without placeholder email - Remove trailing blank line from duplicate test removal Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/selftune/agent-guidance.ts | 5 +---- cli/selftune/dashboard-server.ts | 9 +++------ cli/selftune/init.ts | 4 +++- tests/alpha-upload/status.test.ts | 5 ++--- tests/signal-orchestrate.test.ts | 11 +++++------ 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/cli/selftune/agent-guidance.ts b/cli/selftune/agent-guidance.ts index e28e4a7e..be0465f7 100644 --- a/cli/selftune/agent-guidance.ts +++ b/cli/selftune/agent-guidance.ts @@ -1,10 +1,7 @@ import { getAlphaLinkState } from "./alpha-identity.js"; import type { AgentCommandGuidance, AlphaIdentity, AlphaLinkState } from "./types.js"; -function buildAlphaInitCommand(options?: { - email?: string; - force?: boolean; -}): string { +function buildAlphaInitCommand(options?: { email?: string; force?: boolean }): string { const parts = ["selftune", "init", "--alpha"]; if (options?.email?.trim()) { parts.push("--alpha-email", options.email); diff --git a/cli/selftune/dashboard-server.ts b/cli/selftune/dashboard-server.ts index 3761090a..9508934f 100644 --- a/cli/selftune/dashboard-server.ts +++ b/cli/selftune/dashboard-server.ts @@ -17,13 +17,10 @@ */ import type { Database } from "bun:sqlite"; -import { existsSync, readFileSync, watchFile, unwatchFile } from "node:fs"; +import { existsSync, readFileSync, unwatchFile, watchFile } from "node:fs"; import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; import type { BadgeFormat } from "./badge/badge-svg.js"; -import { - LOG_DIR, - SELFTUNE_CONFIG_DIR, -} from "./constants.js"; +import { LOG_DIR, SELFTUNE_CONFIG_DIR } from "./constants.js"; import type { HealthResponse, OverviewResponse, @@ -235,7 +232,7 @@ export async function startDashboardServer( }, SSE_KEEPALIVE_MS); // -- SQLite WAL watcher for push-based updates ------------------------------ - const walPath = DB_PATH + "-wal"; + const walPath = `${DB_PATH}-wal`; let walWatcherActive = false; let fsDebounceTimer: ReturnType | null = null; diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index 49fc686f..d11de5c1 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -538,7 +538,9 @@ export async function runInit(opts: InitOptions): Promise { process.stderr.write(`[alpha] Could not open browser. Visit the URL above manually.\n`); } } else { - process.stderr.write(`[alpha] Visit ${grant.verification_url}?code=${grant.user_code} to approve.\n`); + process.stderr.write( + `[alpha] Visit ${grant.verification_url}?code=${grant.user_code} to approve.\n`, + ); } process.stderr.write("[alpha] Polling"); diff --git a/tests/alpha-upload/status.test.ts b/tests/alpha-upload/status.test.ts index cbfdfb8b..740b2154 100644 --- a/tests/alpha-upload/status.test.ts +++ b/tests/alpha-upload/status.test.ts @@ -242,7 +242,7 @@ describe("formatAlphaStatus", () => { const output = formatAlphaStatus(null); expect(output).toContain("not enrolled"); expect(output).toContain("Next command"); - expect(output).toContain("selftune init --alpha --alpha-email "); + expect(output).toContain("selftune init --alpha"); }); test("shows enrolled status with queue stats", () => { @@ -298,8 +298,7 @@ describe("formatAlphaStatus", () => { const output = formatAlphaStatus(info); expect(output).toContain("Next command"); - expect(output).toContain("selftune init --alpha --alpha-email"); - expect(output).toContain("--force"); + expect(output).toContain("selftune init --alpha --force"); }); test("shows linked but not enrolled state when cloud identity exists", () => { diff --git a/tests/signal-orchestrate.test.ts b/tests/signal-orchestrate.test.ts index 0aee5980..f3dc292a 100644 --- a/tests/signal-orchestrate.test.ts +++ b/tests/signal-orchestrate.test.ts @@ -3,6 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getDb } from "../cli/selftune/localdb/db.js"; +import { writeImprovementSignalToDb } from "../cli/selftune/localdb/direct-write.js"; import { acquireLock, markSignalsConsumed, @@ -11,10 +13,6 @@ import { } from "../cli/selftune/orchestrate.js"; import type { SkillStatus } from "../cli/selftune/status.js"; import type { ImprovementSignalRecord, MonitoringSnapshot } from "../cli/selftune/types.js"; -import { readJsonl } from "../cli/selftune/utils/jsonl.js"; -import { writeImprovementSignalToDb } from "../cli/selftune/localdb/direct-write.js"; -import { queryImprovementSignals } from "../cli/selftune/localdb/queries.js"; -import { getDb } from "../cli/selftune/localdb/db.js"; // --------------------------------------------------------------------------- // Helpers @@ -236,7 +234,9 @@ describe("markSignalsConsumed", () => { const db = getDb(); const updated = db - .query("SELECT * FROM improvement_signals WHERE session_id IN ('s1','s2','s3') ORDER BY timestamp ASC") + .query( + "SELECT * FROM improvement_signals WHERE session_id IN ('s1','s2','s3') ORDER BY timestamp ASC", + ) .all(); expect(updated).toHaveLength(3); @@ -257,7 +257,6 @@ describe("markSignalsConsumed", () => { // Should not throw with empty list expect(() => markSignalsConsumed([], "run_123")).not.toThrow(); }); - }); // --------------------------------------------------------------------------- From 6c223150d93d66053fbb31e0bf5a5c4378a173c1 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:24:33 +0300 Subject: [PATCH 07/12] test: add regression test for default SQLite upload-staging path Writes canonical records directly into SQLite tables (simulating the runtime hook path), then calls stageCanonicalRecords(db) with default args to exercise the SQLite read path. Verifies all 4 record kinds are staged and envelope fields survive the round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/alpha-upload/staging.test.ts | 118 +++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/alpha-upload/staging.test.ts b/tests/alpha-upload/staging.test.ts index 02e30c90..8ddbffd8 100644 --- a/tests/alpha-upload/staging.test.ts +++ b/tests/alpha-upload/staging.test.ts @@ -786,6 +786,124 @@ describe("buildV2PushPayload (staging-based)", () => { expect((run.skill_actions as unknown[]).length).toBe(1); }); + test("default SQLite path: stages records written via direct-write (no JSONL)", () => { + // Write canonical records directly into SQLite tables (simulating the runtime path + // where hooks write via direct-write.ts, not via JSONL) + db.run( + `INSERT INTO sessions + (session_id, started_at, ended_at, platform, model, completion_status, + source_session_kind, schema_version, normalized_at, + normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sqlite-sess-1", + "2026-03-18T09:00:00.000Z", + "2026-03-18T09:30:00.000Z", + "claude_code", + "opus", + "completed", + "interactive", + "2.0", + "2026-03-18T10:00:00.000Z", + "1.0.0", + "hook", + JSON.stringify({ event_type: "SessionStop" }), + ], + ); + db.run( + `INSERT INTO prompts + (prompt_id, session_id, occurred_at, prompt_kind, is_actionable, prompt_index, prompt_text, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sqlite-sess-1:p0", + "sqlite-sess-1", + "2026-03-18T09:01:00.000Z", + "user", + 1, + 0, + "improve my skills", + "2.0", + "claude_code", + "2026-03-18T10:00:00.000Z", + "1.0.0", + "hook", + JSON.stringify({ event_type: "UserPromptSubmit" }), + ], + ); + db.run( + `INSERT INTO skill_invocations + (skill_invocation_id, session_id, occurred_at, skill_name, invocation_mode, + triggered, confidence, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sqlite-sess-1:s:selftune:0", + "sqlite-sess-1", + "2026-03-18T09:02:00.000Z", + "selftune", + "implicit", + 1, + 0.95, + "2.0", + "claude_code", + "2026-03-18T10:00:00.000Z", + "1.0.0", + "hook", + JSON.stringify({}), + ], + ); + db.run( + `INSERT INTO execution_facts + (session_id, occurred_at, tool_calls_json, total_tool_calls, + assistant_turns, errors_encountered, + schema_version, platform, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sqlite-sess-1", + "2026-03-18T09:30:00.000Z", + JSON.stringify({ Read: 2, Write: 1 }), + 3, + 4, + 0, + "2.0", + "claude_code", + "2026-03-18T10:00:00.000Z", + "1.0.0", + "hook", + JSON.stringify({}), + ], + ); + + // Call stageCanonicalRecords with default logPath — this triggers the SQLite read path + const staged = stageCanonicalRecords(db); + expect(staged).toBeGreaterThanOrEqual(4); // session + prompt + invocation + execution_fact + + // Verify staged rows exist and contain valid canonical JSON + const rows = db.query("SELECT * FROM canonical_upload_staging ORDER BY local_seq").all() as Array< + Record + >; + const kinds = rows.map((r) => r.record_kind); + expect(kinds).toContain("session"); + expect(kinds).toContain("prompt"); + expect(kinds).toContain("skill_invocation"); + expect(kinds).toContain("execution_fact"); + + // Verify the staged JSON passes contract validation by building a payload + const result = buildV2PushPayload(db); + if (!result) throw new Error("expected staged payload"); + expect(result.payload.canonical.sessions).toHaveLength(1); + expect(result.payload.canonical.prompts).toHaveLength(1); + expect(result.payload.canonical.skill_invocations).toHaveLength(1); + expect(result.payload.canonical.execution_facts).toHaveLength(1); + + // Spot-check envelope fields survived the round-trip + const sess = result.payload.canonical.sessions[0] as Record; + expect(sess.normalizer_version).toBe("1.0.0"); + expect(sess.capture_mode).toBe("hook"); + expect(sess.raw_source_ref).toEqual({ event_type: "SessionStop" }); + }); + test("no hardcoded provenance fields -- canonical fields preserved from source", () => { const session = makeCanonicalSessionRecord("sess-prov", { capture_mode: "hook", From 2967d6ca1db585b105b72d1b96702fc57b673059 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:25:39 +0300 Subject: [PATCH 08/12] style: fix biome formatting in queries, observability, orchestrate, staging test Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/selftune/localdb/queries.ts | 21 ++++++++++++--------- cli/selftune/observability.ts | 3 +-- cli/selftune/orchestrate.ts | 5 +---- tests/alpha-upload/staging.test.ts | 10 ++++------ 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/cli/selftune/localdb/queries.ts b/cli/selftune/localdb/queries.ts index 7975a019..4c72682b 100644 --- a/cli/selftune/localdb/queries.ts +++ b/cli/selftune/localdb/queries.ts @@ -641,9 +641,10 @@ export function queryCanonicalRecordsForStaging(db: Database): Record { // Envelope fields expect(p.schema_version).toBe("2.0"); expect(typeof p.client_version).toBe("string"); - expect(p.push_id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); + expect(p.push_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); expect(typeof p.normalizer_version).toBe("string"); // Canonical arrays exist with correct lengths @@ -880,9 +878,9 @@ describe("buildV2PushPayload (staging-based)", () => { expect(staged).toBeGreaterThanOrEqual(4); // session + prompt + invocation + execution_fact // Verify staged rows exist and contain valid canonical JSON - const rows = db.query("SELECT * FROM canonical_upload_staging ORDER BY local_seq").all() as Array< - Record - >; + const rows = db + .query("SELECT * FROM canonical_upload_staging ORDER BY local_seq") + .all() as Array>; const kinds = rows.map((r) => r.record_kind); expect(kinds).toContain("session"); expect(kinds).toContain("prompt"); From e16a23f9eeda59c27b9173d308ca81d7a651ad48 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:53:41 +0300 Subject: [PATCH 09/12] Fix CI regressions and follow-up runtime issues --- apps/local-dashboard/package.json | 1 + bun.lock | 105 ++++++----- cli/selftune/activation-rules.ts | 28 ++- cli/selftune/localdb/direct-write.ts | 18 +- cli/selftune/localdb/queries.ts | 3 +- cli/selftune/normalization.ts | 3 + cli/selftune/observability.ts | 15 +- cli/selftune/orchestrate.ts | 6 +- cli/selftune/types.ts | 4 +- packages/telemetry-contract/package.json | 4 + packages/telemetry-contract/src/schemas.ts | 196 +++++++++++++++++++++ tests/autonomy-proof.test.ts | 3 + tests/hooks/auto-activate.test.ts | 27 +++ tests/localdb/read-queries.test.ts | 61 +++++++ tests/localdb/write.test.ts | 11 ++ tests/orchestrate.test.ts | 1 + 16 files changed, 414 insertions(+), 72 deletions(-) create mode 100644 packages/telemetry-contract/src/schemas.ts diff --git a/apps/local-dashboard/package.json b/apps/local-dashboard/package.json index 5485b3d2..49fd1eb8 100644 --- a/apps/local-dashboard/package.json +++ b/apps/local-dashboard/package.json @@ -36,6 +36,7 @@ "@selftune/ui": "workspace:*" }, "devDependencies": { + "@selftune/telemetry-contract": "workspace:*", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@tailwindcss/vite": "^4.2.1", diff --git a/bun.lock b/bun.lock index a396b3d3..1de894f3 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "zod": "^4.3.6", }, "devDependencies": { + "@selftune/telemetry-contract": "workspace:*", "@tailwindcss/vite": "^4.2.1", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", @@ -57,7 +58,7 @@ "name": "@selftune/telemetry-contract", "version": "1.0.0", "dependencies": { - "zod": "^3.24.0", + "zod": "^4.3.6", }, }, "packages/ui": { @@ -163,23 +164,23 @@ "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], - "@biomejs/biome": ["@biomejs/biome@2.4.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.7", "@biomejs/cli-darwin-x64": "2.4.7", "@biomejs/cli-linux-arm64": "2.4.7", "@biomejs/cli-linux-arm64-musl": "2.4.7", "@biomejs/cli-linux-x64": "2.4.7", "@biomejs/cli-linux-x64-musl": "2.4.7", "@biomejs/cli-win32-arm64": "2.4.7", "@biomejs/cli-win32-x64": "2.4.7" }, "bin": { "biome": "bin/biome" } }, "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng=="], + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -191,7 +192,7 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.55.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-WEuKyoe9CA7dfcFBnNbL0ndbCNcptaEYBygfFo9X1qEG+HD7xku4CYIplw6sbAHJavesZWbVBHeRSpvri0eKqw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.57.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], @@ -397,39 +398,39 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.91.3", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-D8jsCexxS5crZxAeiH6VlLHOUzmHOxeW5c11y8rZu0c34u/cy18hUKQXA/gn1Ila3ZIFzP+Pzv76YnliC0EtZQ=="], "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], @@ -445,7 +446,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -467,7 +468,7 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -535,7 +536,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -545,7 +546,7 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -685,7 +686,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -877,7 +878,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -897,29 +898,29 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1181,7 +1182,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.0.8", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-DVAyeo95TQ/OvaHugLm5V0Dqz3al+dnoP3mZdWWxKJ33IYG1jN5B3sGZyNaYsfzm7JsWokfksSzDl83LnmMing=="], + "shadcn": ["shadcn@4.1.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1247,7 +1248,7 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1289,7 +1290,7 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -1381,11 +1382,9 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@selftune/telemetry-contract/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], diff --git a/cli/selftune/activation-rules.ts b/cli/selftune/activation-rules.ts index 36b00770..e7649644 100644 --- a/cli/selftune/activation-rules.ts +++ b/cli/selftune/activation-rules.ts @@ -29,8 +29,12 @@ const postSessionDiagnostic: ActivationRule = { // Count queries for this session — SQLite is the default path let queries: Array<{ session_id: string; query: string }>; if (ctx.query_log_path === QUERY_LOG) { - const db = getDb(); - queries = queryQueryLog(db) as Array<{ session_id: string; query: string }>; + try { + const db = getDb(); + queries = queryQueryLog(db) as Array<{ session_id: string; query: string }>; + } catch { + return null; + } } else { // test/custom-path fallback queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path); @@ -42,10 +46,14 @@ const postSessionDiagnostic: ActivationRule = { // Count skill usages for this session — SQLite is the default path let skillUsages: Array<{ session_id: string }>; if (ctx.query_log_path === QUERY_LOG) { - const db = getDb(); - skillUsages = (querySkillUsageRecords(db) as Array<{ session_id: string }>).filter( - (s) => s.session_id === ctx.session_id, - ); + try { + const db = getDb(); + skillUsages = (querySkillUsageRecords(db) as Array<{ session_id: string }>).filter( + (s) => s.session_id === ctx.session_id, + ); + } catch { + return null; + } } else { // test/custom-path fallback const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl"); @@ -118,8 +126,12 @@ const staleEvolution: ActivationRule = { // Check last evolution timestamp — SQLite is the default path let auditEntries: Array<{ timestamp: string; action: string }>; if (ctx.evolution_audit_log_path === EVOLUTION_AUDIT_LOG) { - const db = getDb(); - auditEntries = queryEvolutionAudit(db) as Array<{ timestamp: string; action: string }>; + try { + const db = getDb(); + auditEntries = queryEvolutionAudit(db) as Array<{ timestamp: string; action: string }>; + } catch { + return null; + } } else { // test/custom-path fallback auditEntries = readJsonl<{ timestamp: string; action: string }>(ctx.evolution_audit_log_path); diff --git a/cli/selftune/localdb/direct-write.ts b/cli/selftune/localdb/direct-write.ts index a7dd7492..38abdbde 100644 --- a/cli/selftune/localdb/direct-write.ts +++ b/cli/selftune/localdb/direct-write.ts @@ -87,6 +87,17 @@ function safeWrite(label: string, fn: (db: Database) => void): boolean { } } +function safeWriteResult(label: string, fn: (db: Database) => T): T | null { + try { + return fn(getDb()); + } catch (err) { + if (process.env.DEBUG || process.env.NODE_ENV === "development") { + console.error(`[direct-write] ${label} failed:`, err); + } + return null; + } +} + // -- Canonical record dispatcher ----------------------------------------------- export function writeCanonicalToDb(record: CanonicalRecord): boolean { @@ -380,7 +391,7 @@ export function updateSignalConsumed( signalType: string, runId: string, ): boolean { - return safeWrite("signal-consumed", (db) => { + const result = safeWriteResult("signal-consumed", (db) => getStmt( db, "signal-consumed", @@ -389,8 +400,9 @@ export function updateSignalConsumed( SET consumed = 1, consumed_at = ?, consumed_by_run = ? WHERE session_id = ? AND query = ? AND signal_type = ? AND consumed = 0 `, - ).run(new Date().toISOString(), runId, sessionId, query, signalType); - }); + ).run(new Date().toISOString(), runId, sessionId, query, signalType), + ); + return result?.changes > 0; } // -- Internal insert helpers (used by cached statements) ---------------------- diff --git a/cli/selftune/localdb/queries.ts b/cli/selftune/localdb/queries.ts index 4c72682b..66c20f64 100644 --- a/cli/selftune/localdb/queries.ts +++ b/cli/selftune/localdb/queries.ts @@ -695,7 +695,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record { const alphaIdentity = readAlphaIdentity(SELFTUNE_CONFIG_PATH); const db = getDb(); + const versionChecksPromise = checkVersionHealth(); + const alphaQueueChecksPromise = checkAlphaQueueHealth(db, alphaIdentity?.enrolled === true); + const logChecks = checkLogHealth(); + const evolutionAuditLogCheck = logChecks.find((check) => check.name === "log_evolution_audit"); + const evolutionChecks = evolutionAuditLogCheck + ? [{ ...evolutionAuditLogCheck, name: "evolution_audit" }] + : checkEvolutionHealth(); const allChecks = [ ...checkConfigHealth(), - ...checkLogHealth(), + ...logChecks, ...checkHookInstallation(), - ...checkEvolutionHealth(), + ...evolutionChecks, ...checkDashboardIntegrityHealth(), ...checkSkillVersionSync(), - ...(await checkVersionHealth()), + ...(await versionChecksPromise), ...checkCloudLinkHealth(alphaIdentity), - ...(await checkAlphaQueueHealth(db, alphaIdentity?.enrolled === true)), + ...(await alphaQueueChecksPromise), ]; const passed = allChecks.filter((c) => c.status === "pass").length; const failed = allChecks.filter((c) => c.status === "fail").length; diff --git a/cli/selftune/orchestrate.ts b/cli/selftune/orchestrate.ts index 19c020c4..0ddf5625 100644 --- a/cli/selftune/orchestrate.ts +++ b/cli/selftune/orchestrate.ts @@ -36,6 +36,7 @@ import { computeStatus } from "./status.js"; import type { SyncResult } from "./sync.js"; import { createDefaultSyncOptions, syncSources } from "./sync.js"; import type { + AlphaIdentity, EvolutionAuditEntry, ImprovementSignalRecord, QueryLogRecord, @@ -386,6 +387,7 @@ export interface OrchestrateDeps { resolveSkillPath?: (skillName: string) => string | undefined; readGradingResults?: (skillName: string) => ReturnType; readSignals?: () => ImprovementSignalRecord[]; + readAlphaIdentity?: () => AlphaIdentity | null; } // --------------------------------------------------------------------------- @@ -703,6 +705,8 @@ export async function orchestrate( }); const _resolveSkillPath = deps.resolveSkillPath ?? defaultResolveSkillPath; const _readGradingResults = deps.readGradingResults ?? readGradingResultsForSkill; + const _readAlphaIdentity = + deps.readAlphaIdentity ?? (() => readAlphaIdentity(SELFTUNE_CONFIG_PATH)); // Lazy-load evolve and watch to avoid circular imports const _evolve = deps.evolve ?? (await import("./evolution/evolve.js")).evolve; @@ -975,7 +979,7 @@ export async function orchestrate( // ------------------------------------------------------------------------- // Step 9: Alpha upload (fail-open — never blocks the orchestrate loop) // ------------------------------------------------------------------------- - const alphaIdentity = readAlphaIdentity(SELFTUNE_CONFIG_PATH); + const alphaIdentity = _readAlphaIdentity(); if (alphaIdentity?.enrolled) { try { console.error("[orchestrate] Running alpha upload cycle..."); diff --git a/cli/selftune/types.ts b/cli/selftune/types.ts index 68c05475..83dd2958 100644 --- a/cli/selftune/types.ts +++ b/cli/selftune/types.ts @@ -125,7 +125,7 @@ export type { CanonicalSessionRecord, CanonicalSkillInvocationRecord, CanonicalSourceSessionKind, -} from "@selftune/telemetry-contract"; +} from "@selftune/telemetry-contract/types"; // --------------------------------------------------------------------------- // Canonical normalization types (local + cloud projection layer) // --------------------------------------------------------------------------- @@ -138,7 +138,7 @@ export { CANONICAL_RECORD_KINDS, CANONICAL_SCHEMA_VERSION, CANONICAL_SOURCE_SESSION_KINDS, -} from "@selftune/telemetry-contract"; +} from "@selftune/telemetry-contract/types"; // --------------------------------------------------------------------------- // Transcript parsing diff --git a/packages/telemetry-contract/package.json b/packages/telemetry-contract/package.json index ca1dbdcf..32ec30f0 100644 --- a/packages/telemetry-contract/package.json +++ b/packages/telemetry-contract/package.json @@ -13,8 +13,12 @@ }, "exports": { ".": "./index.ts", + "./schemas": "./src/schemas.ts", "./types": "./src/types.ts", "./validators": "./src/validators.ts", "./fixtures": "./fixtures/index.ts" + }, + "dependencies": { + "zod": "^4.3.6" } } diff --git a/packages/telemetry-contract/src/schemas.ts b/packages/telemetry-contract/src/schemas.ts new file mode 100644 index 00000000..a99ac715 --- /dev/null +++ b/packages/telemetry-contract/src/schemas.ts @@ -0,0 +1,196 @@ +import { z } from "zod"; +import { + CANONICAL_CAPTURE_MODES, + CANONICAL_COMPLETION_STATUSES, + CANONICAL_INVOCATION_MODES, + CANONICAL_PLATFORMS, + CANONICAL_PROMPT_KINDS, + CANONICAL_RECORD_KINDS, + CANONICAL_SCHEMA_VERSION, + CANONICAL_SOURCE_SESSION_KINDS, +} from "./types.js"; + +export const canonicalPlatformSchema = z.enum(CANONICAL_PLATFORMS); +export const captureModeSchema = z.enum(CANONICAL_CAPTURE_MODES); +export const sourceSessionKindSchema = z.enum(CANONICAL_SOURCE_SESSION_KINDS); +export const promptKindSchema = z.enum(CANONICAL_PROMPT_KINDS); +export const invocationModeSchema = z.enum(CANONICAL_INVOCATION_MODES); +export const completionStatusSchema = z.enum(CANONICAL_COMPLETION_STATUSES); +export const recordKindSchema = z.enum(CANONICAL_RECORD_KINDS); + +export const rawSourceRefSchema = z.object({ + path: z.string().optional(), + line: z.number().int().nonnegative().optional(), + event_type: z.string().optional(), + raw_id: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export const canonicalRecordBaseSchema = z.object({ + record_kind: recordKindSchema, + schema_version: z.literal(CANONICAL_SCHEMA_VERSION), + normalizer_version: z.string().min(1), + normalized_at: z.string().datetime(), + platform: canonicalPlatformSchema, + capture_mode: captureModeSchema, + raw_source_ref: rawSourceRefSchema, +}); + +export const canonicalSessionRecordBaseSchema = canonicalRecordBaseSchema.extend({ + source_session_kind: sourceSessionKindSchema, + session_id: z.string().min(1), +}); + +export const CanonicalSessionRecordSchema = canonicalSessionRecordBaseSchema.extend({ + record_kind: z.literal("session"), + external_session_id: z.string().optional(), + parent_session_id: z.string().optional(), + agent_id: z.string().optional(), + agent_type: z.string().optional(), + agent_cli: z.string().optional(), + session_key: z.string().optional(), + channel: z.string().optional(), + workspace_path: z.string().optional(), + repo_root: z.string().optional(), + repo_remote: z.string().optional(), + branch: z.string().optional(), + commit_sha: z.string().optional(), + permission_mode: z.string().optional(), + approval_policy: z.string().optional(), + sandbox_policy: z.string().optional(), + provider: z.string().optional(), + model: z.string().optional(), + started_at: z.string().datetime().optional(), + ended_at: z.string().datetime().optional(), + completion_status: completionStatusSchema.optional(), + end_reason: z.string().optional(), +}); + +export const CanonicalPromptRecordSchema = canonicalSessionRecordBaseSchema.extend({ + record_kind: z.literal("prompt"), + prompt_id: z.string().min(1), + occurred_at: z.string().datetime(), + prompt_text: z.string().min(1), + prompt_hash: z.string().optional(), + prompt_kind: promptKindSchema, + is_actionable: z.boolean(), + prompt_index: z.number().int().nonnegative().optional(), + parent_prompt_id: z.string().optional(), + source_message_id: z.string().optional(), +}); + +export const CanonicalSkillInvocationRecordSchema = canonicalSessionRecordBaseSchema.extend({ + record_kind: z.literal("skill_invocation"), + skill_invocation_id: z.string().min(1), + occurred_at: z.string().datetime(), + matched_prompt_id: z.string().min(1).optional(), + skill_name: z.string().min(1), + skill_path: z.string().optional(), + skill_version_hash: z.string().optional(), + invocation_mode: invocationModeSchema, + triggered: z.boolean(), + confidence: z.number().min(0).max(1), + tool_name: z.string().optional(), + tool_call_id: z.string().optional(), + agent_type: z.string().optional(), +}); + +export const CanonicalExecutionFactRecordSchema = canonicalSessionRecordBaseSchema.extend({ + record_kind: z.literal("execution_fact"), + execution_fact_id: z.string().min(1), + occurred_at: z.string().datetime(), + prompt_id: z.string().optional(), + tool_calls_json: z.record(z.string(), z.number().finite()), + total_tool_calls: z.number().int().nonnegative(), + bash_commands_redacted: z.array(z.string()).optional(), + assistant_turns: z.number().int().nonnegative(), + errors_encountered: z.number().int().nonnegative(), + input_tokens: z.number().int().nonnegative().optional(), + output_tokens: z.number().int().nonnegative().optional(), + duration_ms: z.number().nonnegative().optional(), + completion_status: completionStatusSchema.optional(), + end_reason: z.string().optional(), +}); + +export const CanonicalNormalizationRunRecordSchema = canonicalRecordBaseSchema.extend({ + record_kind: z.literal("normalization_run"), + run_id: z.string().min(1), + run_at: z.string().datetime(), + raw_records_seen: z.number().int().nonnegative(), + canonical_records_written: z.number().int().nonnegative(), + repair_applied: z.boolean(), +}); + +export const CanonicalEvolutionEvidenceRecordSchema = z.object({ + evidence_id: z.string().min(1), + skill_name: z.string().min(1), + proposal_id: z.string().optional(), + target: z.string().min(1), + stage: z.string().min(1), + rationale: z.string().optional(), + confidence: z.number().min(0).max(1).optional(), + original_text: z.string().optional(), + proposed_text: z.string().optional(), + eval_set_json: z.unknown().optional(), + validation_json: z.unknown().optional(), + raw_source_ref: rawSourceRefSchema.optional(), +}); + +export const OrchestrateRunSkillActionSchema = z.object({ + skill: z.string().min(1), + action: z.enum(["evolve", "watch", "skip"]), + reason: z.string(), + deployed: z.boolean().optional(), + rolledBack: z.boolean().optional(), + alert: z.string().nullable().optional(), + elapsed_ms: z.number().nonnegative().optional(), + llm_calls: z.number().int().nonnegative().optional(), +}); + +export const PushOrchestrateRunRecordSchema = z.object({ + run_id: z.string().min(1), + timestamp: z.string().datetime(), + elapsed_ms: z.number().int().nonnegative(), + dry_run: z.boolean(), + approval_mode: z.enum(["auto", "review"]), + total_skills: z.number().int().nonnegative(), + evaluated: z.number().int().nonnegative(), + evolved: z.number().int().nonnegative(), + deployed: z.number().int().nonnegative(), + watched: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + skill_actions: z.array(OrchestrateRunSkillActionSchema), +}); + +export const PushPayloadV2Schema = z.object({ + schema_version: z.literal("2.0"), + client_version: z.string().min(1), + // Queue-generated push IDs are typically UUIDs, but the wire contract only + // requires a stable non-empty idempotency key. + push_id: z.string().min(1), + normalizer_version: z.string().min(1), + canonical: z.object({ + sessions: z.array(CanonicalSessionRecordSchema).min(0), + prompts: z.array(CanonicalPromptRecordSchema).min(0), + skill_invocations: z.array(CanonicalSkillInvocationRecordSchema).min(0), + execution_facts: z.array(CanonicalExecutionFactRecordSchema).min(0), + normalization_runs: z.array(CanonicalNormalizationRunRecordSchema).min(0), + evolution_evidence: z.array(CanonicalEvolutionEvidenceRecordSchema).optional(), + orchestrate_runs: z.array(PushOrchestrateRunRecordSchema).optional(), + }), +}); + +export type PushPayloadV2 = z.infer; +export type ZodCanonicalSessionRecord = z.infer; +export type ZodCanonicalPromptRecord = z.infer; +export type ZodCanonicalSkillInvocationRecord = z.infer< + typeof CanonicalSkillInvocationRecordSchema +>; +export type ZodCanonicalExecutionFactRecord = z.infer; +export type ZodCanonicalNormalizationRunRecord = z.infer< + typeof CanonicalNormalizationRunRecordSchema +>; +export type ZodCanonicalEvolutionEvidenceRecord = z.infer< + typeof CanonicalEvolutionEvidenceRecordSchema +>; +export type ZodPushOrchestrateRunRecord = z.infer; diff --git a/tests/autonomy-proof.test.ts b/tests/autonomy-proof.test.ts index 03c78708..8d2e3ba7 100644 --- a/tests/autonomy-proof.test.ts +++ b/tests/autonomy-proof.test.ts @@ -283,6 +283,7 @@ describe("autonomy proof: autonomous deploy end-to-end", () => { readAuditEntries: () => [], resolveSkillPath: (name) => (name === "test-autonomy" ? skillPath : undefined), readGradingResults: () => [], + readAlphaIdentity: () => null, // This is the key: evolve() does real file I/O via deployProposal, but we // control the LLM-dependent steps (pattern extraction, proposal, validation) // by returning deterministic results. @@ -407,6 +408,7 @@ describe("autonomy proof: autonomous deploy end-to-end", () => { readAuditEntries: () => [], resolveSkillPath: () => skillPath, readGradingResults: () => [], + readAlphaIdentity: () => null, evolve: async (opts) => { evolveDryRunArg = opts.dryRun; return { @@ -816,6 +818,7 @@ describe("autonomy proof: orchestrate watches recently-evolved skills", () => { ? "/tmp/skills/recently-deployed-skill/SKILL.md" : undefined, readGradingResults: () => [], + readAlphaIdentity: () => null, evolve: async () => ({ proposal: null, validation: null, diff --git a/tests/hooks/auto-activate.test.ts b/tests/hooks/auto-activate.test.ts index b6492646..b6b77574 100644 --- a/tests/hooks/auto-activate.test.ts +++ b/tests/hooks/auto-activate.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { EVOLUTION_AUDIT_LOG, QUERY_LOG } from "../../cli/selftune/constants.js"; import { evaluateRules, loadSessionState, saveSessionState, } from "../../cli/selftune/hooks/auto-activate.js"; +import { _setTestDb, openDb } from "../../cli/selftune/localdb/db.js"; import type { ActivationContext, ActivationRule, SessionState } from "../../cli/selftune/types.js"; let tmpDir: string; @@ -16,6 +18,7 @@ beforeEach(() => { }); afterEach(() => { + _setTestDb(null); rmSync(tmpDir, { recursive: true, force: true }); }); @@ -218,6 +221,18 @@ describe("default activation rules", () => { expect(suggestion).toBeNull(); }); + test("post-session diagnostic fails open when default SQLite reads throw", async () => { + const { DEFAULT_RULES } = await import("../../cli/selftune/activation-rules.js"); + const rule = DEFAULT_RULES.find((r) => r.id === "post-session-diagnostic"); + + const db = openDb(":memory:"); + _setTestDb(db); + db.close(); + + const ctx = makeContext({ query_log_path: QUERY_LOG }); + expect(rule?.evaluate(ctx)).toBeNull(); + }); + test("grading-threshold rule fires when pass rate < 0.6", async () => { const { DEFAULT_RULES } = await import("../../cli/selftune/activation-rules.js"); const rule = DEFAULT_RULES.find((r) => r.id === "grading-threshold-breach"); @@ -267,6 +282,18 @@ describe("default activation rules", () => { expect(suggestion).toBeNull(); }); + test("stale-evolution fails open when default SQLite reads throw", async () => { + const { DEFAULT_RULES } = await import("../../cli/selftune/activation-rules.js"); + const rule = DEFAULT_RULES.find((r) => r.id === "stale-evolution"); + + const db = openDb(":memory:"); + _setTestDb(db); + db.close(); + + const ctx = makeContext({ evolution_audit_log_path: EVOLUTION_AUDIT_LOG }); + expect(rule?.evaluate(ctx)).toBeNull(); + }); + test("stale-evolution rule fires with old audit + pending false negatives", async () => { const { DEFAULT_RULES } = await import("../../cli/selftune/activation-rules.js"); const rule = DEFAULT_RULES.find((r) => r.id === "stale-evolution"); diff --git a/tests/localdb/read-queries.test.ts b/tests/localdb/read-queries.test.ts index 8548b8c3..6cd57da9 100644 --- a/tests/localdb/read-queries.test.ts +++ b/tests/localdb/read-queries.test.ts @@ -8,6 +8,7 @@ import { getPendingProposals, getSkillReportPayload, getSkillsList, + queryCanonicalRecordsForStaging, queryEvolutionAudit, queryEvolutionEvidence, queryImprovementSignals, @@ -791,3 +792,63 @@ describe("getPendingProposals", () => { expect(pending[0].action).toBe("validated"); }); }); + +describe("queryCanonicalRecordsForStaging", () => { + let db: Database; + + beforeEach(() => { + db = openDb(":memory:"); + }); + + afterEach(() => { + db.close(); + }); + + it("preserves execution_fact_id when rebuilding execution facts from SQLite", () => { + db.run( + `INSERT INTO sessions (session_id, source_session_kind, platform, schema_version, normalized_at, normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sess-ef", + "interactive", + "claude_code", + "2.0", + "2026-03-17T10:00:00Z", + "norm-1", + "hook", + JSON.stringify({ path: "/tmp/raw.jsonl" }), + ], + ); + db.run( + `INSERT INTO execution_facts + (session_id, occurred_at, prompt_id, tool_calls_json, total_tool_calls, + assistant_turns, errors_encountered, schema_version, platform, normalized_at, + normalizer_version, capture_mode, raw_source_ref) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "sess-ef", + "2026-03-17T10:05:00Z", + "prompt-ef", + JSON.stringify({ Read: 2 }), + 2, + 1, + 0, + "2.0", + "claude_code", + "2026-03-17T10:06:00Z", + "norm-1", + "hook", + JSON.stringify({ path: "/tmp/raw.jsonl", line: 12 }), + ], + ); + + const executionFact = queryCanonicalRecordsForStaging(db).find( + (record) => record.record_kind === "execution_fact", + ) as Record | undefined; + + expect(executionFact).toBeDefined(); + expect(executionFact?.execution_fact_id).toBeDefined(); + expect(typeof executionFact?.execution_fact_id).toBe("string"); + expect(executionFact?.execution_fact_id).toBe("1"); + }); +}); diff --git a/tests/localdb/write.test.ts b/tests/localdb/write.test.ts index 73f87a01..c723bac5 100644 --- a/tests/localdb/write.test.ts +++ b/tests/localdb/write.test.ts @@ -839,4 +839,15 @@ describe("updateSignalConsumed", () => { // consumed_at should be a valid ISO string expect(() => new Date(rows[0].consumed_at as string)).not.toThrow(); }); + + it("returns false when no rows were updated", () => { + const ok = updateSignalConsumed( + "sess-missing", + "missing signal", + "explicit_request", + "run-noop", + ); + + expect(ok).toBe(false); + }); }); diff --git a/tests/orchestrate.test.ts b/tests/orchestrate.test.ts index dce6c1e1..95ef4590 100644 --- a/tests/orchestrate.test.ts +++ b/tests/orchestrate.test.ts @@ -115,6 +115,7 @@ function makeDeps(overrides: Partial = {}): OrchestrateDeps { readAuditEntries: () => [], resolveSkillPath: () => "/fake/path/SKILL.md", readGradingResults: () => [], + readAlphaIdentity: () => null, evolve: async () => ({ proposal: null, validation: null, From d38605e64f64137a896cdf7644b47587f168e6d9 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:20:51 +0300 Subject: [PATCH 10/12] Add Claude workspace wiring helper --- package.json | 1 + scripts/link-claude-workspace.sh | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 scripts/link-claude-workspace.sh diff --git a/package.json b/package.json index b29bd2a4..8419f8f8 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "test:fast": "bun test $(find tests -name '*.test.ts' ! -name 'evolve.test.ts' ! -name 'integration.test.ts' ! -name 'dashboard-server.test.ts' ! -path '*/blog-proof/*')", "test:slow": "bun test tests/evolution/evolve.test.ts tests/evolution/integration.test.ts tests/monitoring/integration.test.ts tests/dashboard/dashboard-server.test.ts", "build:dashboard": "cd apps/local-dashboard && bunx vite build", + "link:claude-workspace": "bash scripts/link-claude-workspace.sh", "sync-version": "bun run scripts/sync-skill-version.ts", "validate:subagents": "bun run scripts/validate-subagent-docs.ts", "prepublishOnly": "bun run sync-version && bun run build:dashboard", diff --git a/scripts/link-claude-workspace.sh b/scripts/link-claude-workspace.sh new file mode 100644 index 00000000..7b0a8c01 --- /dev/null +++ b/scripts/link-claude-workspace.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +skill_src="$repo_root/skill" +claude_skills_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}" +claude_skill_path="$claude_skills_dir/selftune" +backup_path="${claude_skill_path}.backup" + +if ! command -v bun >/dev/null 2>&1; then + echo "bun is required but was not found on PATH." >&2 + exit 1 +fi + +if [ ! -d "$skill_src" ]; then + echo "Expected skill directory at $skill_src" >&2 + exit 1 +fi + +mkdir -p "$claude_skills_dir" + +current_target="" +if [ -L "$claude_skill_path" ]; then + current_target="$(readlink "$claude_skill_path" || true)" +fi + +if [ "$current_target" != "$skill_src" ] && [ -e "$claude_skill_path" ]; then + if [ ! -e "$backup_path" ]; then + mv "$claude_skill_path" "$backup_path" + echo "Backed up existing Claude selftune skill to $backup_path" + else + rm -rf "$claude_skill_path" + fi +fi + +ln -sfn "$skill_src" "$claude_skill_path" +(cd "$repo_root" && bun link >/dev/null) + +resolved_skill_path="$(readlink "$claude_skill_path" || true)" +cli_path="$(command -v selftune || true)" + +cat < $resolved_skill_path +selftune CLI: ${cli_path:-not found on PATH after bun link} + +To verify: + readlink "$claude_skill_path" + command -v selftune +EOF From fd4c8cc8df3493957263d01e91d644d1523b5347 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:41:50 +0300 Subject: [PATCH 11/12] Harden alpha guidance and init validation --- cli/selftune/agent-guidance.ts | 12 +++++- cli/selftune/init.ts | 36 ++++++++++++++++++ cli/selftune/localdb/queries.ts | 3 +- tests/agent-guidance.test.ts | 20 ++++++++++ tests/alpha-upload/staging.test.ts | 5 ++- tests/init/alpha-consent.test.ts | 61 +++++++++++++++++++++++++++++- tests/localdb/read-queries.test.ts | 18 +++++++++ 7 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 tests/agent-guidance.test.ts diff --git a/cli/selftune/agent-guidance.ts b/cli/selftune/agent-guidance.ts index be0465f7..a3422a83 100644 --- a/cli/selftune/agent-guidance.ts +++ b/cli/selftune/agent-guidance.ts @@ -1,10 +1,18 @@ import { getAlphaLinkState } from "./alpha-identity.js"; import type { AgentCommandGuidance, AlphaIdentity, AlphaLinkState } from "./types.js"; +function sanitizeAlphaEmail(email?: string): string | null { + const trimmed = email?.trim(); + if (!trimmed) return null; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null; + return trimmed; +} + function buildAlphaInitCommand(options?: { email?: string; force?: boolean }): string { const parts = ["selftune", "init", "--alpha"]; - if (options?.email?.trim()) { - parts.push("--alpha-email", options.email); + const email = sanitizeAlphaEmail(options?.email); + if (email) { + parts.push("--alpha-email", email); } if (options?.force) { parts.push("--force"); diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index d11de5c1..5394d435 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -458,6 +458,34 @@ export interface InitOptions { alphaName?: string; } +function validateAlphaMetadataFlags(alpha: boolean | undefined, email?: string, name?: string): void { + if ((email !== undefined || name !== undefined) && !alpha) { + throw new Error("--alpha-email and --alpha-name require --alpha"); + } +} + +function assertValidApprovedAlphaCredential(result: { + api_key: string; + cloud_user_id: string; + org_id: string; +}): void { + if (!isValidApiKeyFormat(result.api_key)) { + throw new Error( + "Device-code approval returned an invalid alpha credential. Re-run `selftune init --alpha`.", + ); + } + if (!result.cloud_user_id?.trim()) { + throw new Error( + "Device-code approval did not include a cloud user id. Re-run `selftune init --alpha`.", + ); + } + if (!result.org_id?.trim()) { + throw new Error( + "Device-code approval did not include an alpha org id. Re-run `selftune init --alpha`.", + ); + } +} + // --------------------------------------------------------------------------- // Core init logic // --------------------------------------------------------------------------- @@ -468,6 +496,7 @@ export interface InitOptions { */ export async function runInit(opts: InitOptions): Promise { const { configDir, configPath, force } = opts; + validateAlphaMetadataFlags(opts.alpha, opts.alphaEmail, opts.alphaName); // If config exists and no --force (and no alpha mutation), return existing const hasAlphaMutation = @@ -545,6 +574,7 @@ export async function runInit(opts: InitOptions): Promise { process.stderr.write("[alpha] Polling"); const result = await pollDeviceCode(grant.device_code, grant.interval, grant.expires_in); + assertValidApprovedAlphaCredential(result); process.stderr.write("\n[alpha] Approved!\n"); validatedAlphaIdentity = { @@ -647,6 +677,12 @@ export async function cliMain(): Promise { const configPath = SELFTUNE_CONFIG_PATH; const force = values.force ?? false; const enableAutonomy = values["enable-autonomy"] ?? false; + try { + validateAlphaMetadataFlags(values.alpha, values["alpha-email"], values["alpha-name"]); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } // Check for existing config without force const hasAlphaMutation = !!( diff --git a/cli/selftune/localdb/queries.ts b/cli/selftune/localdb/queries.ts index 66c20f64..46b54325 100644 --- a/cli/selftune/localdb/queries.ts +++ b/cli/selftune/localdb/queries.ts @@ -659,7 +659,7 @@ export function queryCanonicalRecordsForStaging(db: Database): Record { + test("includes a trimmed safe alpha email in next_command", () => { + const guidance = getAlphaGuidanceForState("not_linked", { + email: " user@example.com ", + }); + + expect(guidance.next_command).toBe("selftune init --alpha --alpha-email user@example.com"); + }); + + test("omits unsafe alpha email values from next_command", () => { + const guidance = getAlphaGuidanceForState("not_linked", { + email: "user@example.com --force\nselftune doctor", + }); + + expect(guidance.next_command).toBe("selftune init --alpha"); + }); +}); diff --git a/tests/alpha-upload/staging.test.ts b/tests/alpha-upload/staging.test.ts index 8afe157c..308fbacf 100644 --- a/tests/alpha-upload/staging.test.ts +++ b/tests/alpha-upload/staging.test.ts @@ -713,7 +713,8 @@ describe("buildV2PushPayload (staging-based)", () => { // Envelope fields expect(p.schema_version).toBe("2.0"); expect(typeof p.client_version).toBe("string"); - expect(p.push_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(typeof p.push_id).toBe("string"); + expect(p.push_id.length).toBeGreaterThan(0); expect(typeof p.normalizer_version).toBe("string"); // Canonical arrays exist with correct lengths @@ -875,7 +876,7 @@ describe("buildV2PushPayload (staging-based)", () => { // Call stageCanonicalRecords with default logPath — this triggers the SQLite read path const staged = stageCanonicalRecords(db); - expect(staged).toBeGreaterThanOrEqual(4); // session + prompt + invocation + execution_fact + expect(staged).toBe(4); // exactly session + prompt + invocation + execution_fact // Verify staged rows exist and contain valid canonical JSON const rows = db diff --git a/tests/init/alpha-consent.test.ts b/tests/init/alpha-consent.test.ts index ed6b4dee..85c30410 100644 --- a/tests/init/alpha-consent.test.ts +++ b/tests/init/alpha-consent.test.ts @@ -16,7 +16,13 @@ let tmpDir: string; const originalFetch = globalThis.fetch; const originalEnv = { ...process.env }; -function mockDeviceCodeFlow(): void { +function mockDeviceCodeFlow( + approvalOverrides: Partial<{ + api_key: string; + cloud_user_id: string; + org_id: string; + }> = {}, +): void { process.env.SELFTUNE_ALPHA_ENDPOINT = "https://test.local/api/v1/push"; process.env.SELFTUNE_NO_BROWSER = "1"; globalThis.fetch = (async (url: string) => { @@ -27,6 +33,7 @@ function mockDeviceCodeFlow(): void { api_key: "st_live_testkey123", cloud_user_id: "cloud-user-test", org_id: "org-test", + ...approvalOverrides, }), { status: 200 }, ); @@ -219,6 +226,16 @@ describe("runInit with alpha", () => { expect(config.alpha).toBeUndefined(); }); + test("rejects alpha metadata flags unless --alpha is enabled", async () => { + await expect( + runInit( + makeInitOpts({ + alphaEmail: "user@example.com", + }), + ), + ).rejects.toThrow("--alpha-email and --alpha-name require --alpha"); + }); + test("--no-alpha sets enrolled=false but preserves user_id", async () => { mockDeviceCodeFlow(); @@ -319,4 +336,46 @@ describe("runInit with alpha", () => { expect(identity).not.toBeNull(); expect(identity?.user_id).toBe(raw.alpha?.user_id); }); + + test("fails before persisting an invalid device-code credential", async () => { + mockDeviceCodeFlow({ api_key: "invalid-key" }); + const opts = makeInitOpts({ + alpha: true, + alphaEmail: "user@example.com", + }); + + await expect(runInit(opts)).rejects.toThrow("invalid alpha credential"); + expect(readAlphaIdentity(opts.configPath)).toBeNull(); + }); + + test("fails before persisting a malformed approval payload", async () => { + mockDeviceCodeFlow({ cloud_user_id: "", org_id: "" }); + const opts = makeInitOpts({ + alpha: true, + alphaEmail: "user@example.com", + }); + + await expect(runInit(opts)).rejects.toThrow("did not include a cloud user id"); + expect(readAlphaIdentity(opts.configPath)).toBeNull(); + }); +}); + +describe("cliMain alpha flag validation", () => { + test("rejects standalone --alpha-email without --alpha", () => { + const initPath = new URL("../../cli/selftune/init.ts", import.meta.url).pathname; + const proc = Bun.spawnSync( + [process.execPath, "run", initPath, "--alpha-email", "user@example.com"], + { + cwd: process.cwd(), + env: { ...process.env, HOME: tmpDir }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + expect(proc.exitCode).toBe(1); + expect(new TextDecoder().decode(proc.stderr)).toContain( + "--alpha-email and --alpha-name require --alpha", + ); + }); }); diff --git a/tests/localdb/read-queries.test.ts b/tests/localdb/read-queries.test.ts index 6cd57da9..703551cb 100644 --- a/tests/localdb/read-queries.test.ts +++ b/tests/localdb/read-queries.test.ts @@ -851,4 +851,22 @@ describe("queryCanonicalRecordsForStaging", () => { expect(typeof executionFact?.execution_fact_id).toBe("string"); expect(executionFact?.execution_fact_id).toBe("1"); }); + + it("preserves skill_path when rebuilding skill invocations from SQLite", () => { + seedSkillUsage(db, { + session_id: "sess-skill-path", + skill_invocation_id: "si-skill-path", + skill_name: "Research", + skill_path: "/skills/research/SKILL.md", + }); + + const invocation = queryCanonicalRecordsForStaging(db).find( + (record) => + record.record_kind === "skill_invocation" && + record.skill_invocation_id === "si-skill-path", + ) as Record | undefined; + + expect(invocation).toBeDefined(); + expect(invocation?.skill_path).toBe("/skills/research/SKILL.md"); + }); }); From 67b2e74019994be2877c59a44219d096d8671f74 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:09:26 +0300 Subject: [PATCH 12/12] Fix lint and Claude workspace wiring --- biome.json | 4 +++- cli/selftune/init.ts | 6 +++++- scripts/link-claude-workspace.sh | 19 +++++++++++++------ tests/localdb/read-queries.test.ts | 3 +-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/biome.json b/biome.json index 898b710f..7bf6d70a 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, @@ -19,6 +19,8 @@ "**/*.ts", "**/*.json", "**/*.md", + "!**/.agent/skills", + "!**/.claude/skills", "!**/.claude/worktrees", "!**/test-results", "!**/node_modules", diff --git a/cli/selftune/init.ts b/cli/selftune/init.ts index 5394d435..92886832 100644 --- a/cli/selftune/init.ts +++ b/cli/selftune/init.ts @@ -458,7 +458,11 @@ export interface InitOptions { alphaName?: string; } -function validateAlphaMetadataFlags(alpha: boolean | undefined, email?: string, name?: string): void { +function validateAlphaMetadataFlags( + alpha: boolean | undefined, + email?: string, + name?: string, +): void { if ((email !== undefined || name !== undefined) && !alpha) { throw new Error("--alpha-email and --alpha-name require --alpha"); } diff --git a/scripts/link-claude-workspace.sh b/scripts/link-claude-workspace.sh index 7b0a8c01..81d57a42 100644 --- a/scripts/link-claude-workspace.sh +++ b/scripts/link-claude-workspace.sh @@ -7,6 +7,16 @@ claude_skills_dir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}" claude_skill_path="$claude_skills_dir/selftune" backup_path="${claude_skill_path}.backup" +next_backup_path() { + local candidate="$backup_path" + local suffix=1 + while [ -e "$candidate" ]; do + candidate="${backup_path}.${suffix}" + suffix=$((suffix + 1)) + done + printf '%s\n' "$candidate" +} + if ! command -v bun >/dev/null 2>&1; then echo "bun is required but was not found on PATH." >&2 exit 1 @@ -25,12 +35,9 @@ if [ -L "$claude_skill_path" ]; then fi if [ "$current_target" != "$skill_src" ] && [ -e "$claude_skill_path" ]; then - if [ ! -e "$backup_path" ]; then - mv "$claude_skill_path" "$backup_path" - echo "Backed up existing Claude selftune skill to $backup_path" - else - rm -rf "$claude_skill_path" - fi + resolved_backup_path="$(next_backup_path)" + mv "$claude_skill_path" "$resolved_backup_path" + echo "Backed up existing Claude selftune skill to $resolved_backup_path" fi ln -sfn "$skill_src" "$claude_skill_path" diff --git a/tests/localdb/read-queries.test.ts b/tests/localdb/read-queries.test.ts index 703551cb..6937f959 100644 --- a/tests/localdb/read-queries.test.ts +++ b/tests/localdb/read-queries.test.ts @@ -862,8 +862,7 @@ describe("queryCanonicalRecordsForStaging", () => { const invocation = queryCanonicalRecordsForStaging(db).find( (record) => - record.record_kind === "skill_invocation" && - record.skill_invocation_id === "si-skill-path", + record.record_kind === "skill_invocation" && record.skill_invocation_id === "si-skill-path", ) as Record | undefined; expect(invocation).toBeDefined();