commit 3bd2f8cb6362fa34ad6b52a496a9e0720b0f7b5d Author: Lex Christopherson Date: Tue Mar 10 22:28:37 2026 -0600 Initial commit diff --git a/.bg-shell/manifest.json b/.bg-shell/manifest.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/.bg-shell/manifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..12eb1f19f --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ + +# ── GSD baseline (auto-generated) ── +.gsd/activity/ +.gsd/runtime/ +.gsd/auto.lock +.gsd/metrics.json +.gsd/STATE.md +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.code-workspace +.env +.env.* +!.env.example +node_modules/ +.next/ +/dist/ +!/pkg/dist/ +build/ +__pycache__/ +*.pyc +.venv/ +venv/ +target/ +vendor/ +*.log +coverage/ +.cache/ +tmp/ + +# ── GSD baseline (auto-generated) ── +dist/ +.bg_shell +.gsd*.tgz +.gsd +.artifacts/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..83c74baaf --- /dev/null +++ b/LICENSE @@ -0,0 +1,39 @@ +Business Source License 1.1 + +Licensor: Lex Christopherson +Licensed Work: GSD (gsd-pi) +Additional Use Grant: None +Change Date: 2029-03-10 +Change License: MIT + +Parameters + +Licensor: Lex Christopherson +Licensed Work: The Licensed Work is (c) 2026 Lex Christopherson + +Use Limitation: You may not use the Licensed Work for a Commercial Purpose. +A "Commercial Purpose" means use in a commercial product or service, or use +on behalf of a for-profit entity, unless you have received a separate +commercial license from the Licensor. + +On the Change Date, or the fourth anniversary of the first publicly available +distribution of a specific version of the Licensed Work under this License, +whichever comes first, the Licensor grants you rights under the terms of the +Change License, and the rights granted in the paragraph above terminate. + +For purposes of this License: + +"Commercial Purpose" means use intended for or directed toward commercial +advantage or monetary compensation. + +The Licensor may make additional grants beyond those described above. Any +additional grants will be described in the Additional Use Grant above. + +Notice + +The Business Source License (this document, or the "License") is not an +Open Source license. However, the Licensed Work will eventually be made +available under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. diff --git a/README.md b/README.md new file mode 100644 index 000000000..e29606d3e --- /dev/null +++ b/README.md @@ -0,0 +1,346 @@ +
+ +# GSD + +**The evolution of [Get Shit Done](https://github.com/glittercowboy/get-shit-done) — now a real coding agent.** + +[![npm version](https://img.shields.io/npm/v/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) +[![npm downloads](https://img.shields.io/npm/dm/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) +[![GitHub stars](https://img.shields.io/github/stars/glittercowboy/gsd-2?style=for-the-badge&logo=github&color=181717)](https://github.com/glittercowboy/gsd-2) +[![License](https://img.shields.io/badge/license-BSL%201.1-blue?style=for-the-badge)](LICENSE) + +The original GSD went viral as a prompt framework for Claude Code. It worked, but it was fighting the tool — injecting prompts through slash commands, hoping the LLM would follow instructions, with no actual control over context windows, sessions, or execution. + +This version is different. GSD is now a standalone CLI built on the [Pi SDK](https://github.com/nicholasgasior/pi-coding-agent), which gives it direct TypeScript access to the agent harness itself. That means GSD can actually *do* what v1 could only *ask* the LLM to do: clear context between tasks, inject exactly the right files at dispatch time, manage git branches, track cost and tokens, detect stuck loops, recover from crashes, and auto-advance through an entire milestone without human intervention. + +One command. Walk away. Come back to a built project with clean git history. + +```bash +npm install -g gsd-pi +gsd +``` + +
+ +--- + +## What Changed From v1 + +The original GSD was a collection of markdown prompts installed into `~/.claude/commands/`. It relied entirely on the LLM reading those prompts and doing the right thing. That worked surprisingly well — but it had hard limits: + +- **No context control.** The LLM accumulated garbage over a long session. Quality degraded. +- **No real automation.** "Auto mode" was the LLM calling itself in a loop, burning context on orchestration overhead. +- **No crash recovery.** If the session died mid-task, you started over. +- **No observability.** No cost tracking, no progress dashboard, no stuck detection. + +GSD v2 solves all of these because it's not a prompt framework anymore — it's a TypeScript application that *controls* the agent session. + +| | v1 (Prompt Framework) | v2 (Agent Application) | +|---|---|---| +| Runtime | Claude Code slash commands | Standalone CLI via Pi SDK | +| Context management | Hope the LLM doesn't fill up | Fresh session per task, programmatic | +| Auto mode | LLM self-loop | State machine reading `.gsd/` files | +| Crash recovery | None | Lock files + session forensics | +| Git strategy | LLM writes git commands | Programmatic branch-per-slice, squash merge | +| Cost tracking | None | Per-unit token/cost ledger with dashboard | +| Stuck detection | None | Retry once, then stop with diagnostics | +| Timeout supervision | None | Soft/idle/hard timeouts with recovery steering | +| Context injection | "Read this file" | Pre-inlined into dispatch prompt | +| Roadmap reassessment | Manual | Automatic after each slice completes | +| Skill discovery | None | Auto-detect and install relevant skills during research | + +--- + +## How It Works + +GSD structures work into a hierarchy: + +``` +Milestone → a shippable version (4-10 slices) + Slice → one demoable vertical capability (1-7 tasks) + Task → one context-window-sized unit of work +``` + +The iron rule: **a task must fit in one context window.** If it can't, it's two tasks. + +### The Loop + +Each slice flows through phases automatically: + +``` +Research → Plan → Execute (per task) → Complete → Reassess Roadmap → Next Slice +``` + +**Research** scouts the codebase and relevant docs. **Plan** decomposes the slice into tasks with must-haves (mechanically verifiable outcomes). **Execute** runs each task in a fresh context window with only the relevant files pre-loaded. **Complete** writes the summary, UAT script, marks the roadmap, and commits. **Reassess** checks if the roadmap still makes sense given what was learned. + +### `/gsd auto` — The Main Event + +This is what makes GSD different. Run it, walk away, come back to built software. + +``` +/gsd auto +``` + +Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. + +**What happens under the hood:** + +1. **Fresh session per unit** — Every task, every research phase, every planning step gets a clean 200k-token context window. No accumulated garbage. No "I'll be more concise now." + +2. **Context pre-loading** — The dispatch prompt includes inlined task plans, slice plans, prior task summaries, dependency summaries, roadmap excerpts, and decisions register. The LLM starts with everything it needs instead of spending tool calls reading files. + +3. **Git branch-per-slice** — Each slice gets its own branch (`gsd/M001/S01`). Tasks commit atomically on the branch. When the slice completes, it's squash-merged to main as one clean commit. + +4. **Crash recovery** — A lock file tracks the current unit. If the session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. + +5. **Stuck detection** — If the same unit dispatches twice (the LLM didn't produce the expected artifact), it retries once with a deep diagnostic. If it fails again, auto mode stops with the exact file it expected. + +6. **Timeout supervision** — Soft timeout warns the LLM to wrap up. Idle watchdog detects stalls. Hard timeout pauses auto mode. Recovery steering nudges the LLM to finish durable output before giving up. + +7. **Cost tracking** — Every unit's token usage and cost is captured, broken down by phase, slice, and model. The dashboard shows running totals and projections. Budget ceilings can pause auto mode before overspending. + +8. **Adaptive replanning** — After each slice completes, the roadmap is reassessed. If the work revealed new information that changes the plan, slices are reordered, added, or removed before continuing. + +9. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/gsd auto` to resume from disk state. + +### The `/gsd` Wizard + +When you're not in auto mode, `/gsd` reads disk state and shows contextual options: + +- **No `.gsd/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences. +- **Milestone exists, no roadmap** → Discuss or research the milestone. +- **Roadmap exists, slices pending** → Plan the next slice, or jump straight to auto. +- **Mid-task** → Resume from where you left off. + +The wizard is the on-ramp. Auto mode is the highway. + +--- + +## Getting Started + +### Install + +```bash +npm install -g gsd-pi +``` + +Requires Node.js ≥ 20.6.0. Installs Chromium via Playwright for browser-based verification (non-fatal if it fails). + +### First Run + +```bash +cd your-project +gsd +``` + +On first launch, GSD prompts for optional API keys: +- **Brave Search** — for web research during planning +- **Context7** — for up-to-date library documentation +- **Jina** — for web page content extraction + +All optional. Press Enter to skip any. Keys are stored in `~/.gsd/agent/auth.json` and loaded automatically on subsequent launches. + +### Start Building + +The wizard walks you through describing what you want to build. Once you approve the roadmap: + +``` +/gsd auto +``` + +Walk away. GSD will research, plan, execute, verify, commit, and advance through every slice until the milestone is complete. + +### Commands + +| Command | What it does | +|---------|-------------| +| `/gsd` | Contextual wizard — reads state, shows what's next | +| `/gsd auto` | Start auto mode (fresh session per unit, loops until done) | +| `/gsd stop` | Stop auto mode gracefully | +| `/gsd status` | Progress dashboard overlay | +| `/gsd queue` | Queue future milestones (safe during auto mode) | +| `/gsd discuss` | Discuss implementation decisions before planning | +| `/gsd prefs` | Manage skill preferences (global/project) | +| `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues | +| `Ctrl+Alt+G` | Toggle dashboard overlay | + +--- + +## What GSD Manages For You + +### Context Engineering + +Every dispatch is carefully constructed. The LLM never wastes tool calls on orientation. + +| Artifact | Purpose | +|----------|---------| +| `PROJECT.md` | Living doc — what the project is right now | +| `DECISIONS.md` | Append-only register of architectural decisions | +| `STATE.md` | Quick-glance dashboard — always read first | +| `M001-ROADMAP.md` | Milestone plan with slice checkboxes, risk levels, dependencies | +| `M001-CONTEXT.md` | User decisions from the discuss phase | +| `M001-RESEARCH.md` | Codebase and ecosystem research | +| `S01-PLAN.md` | Slice task decomposition with must-haves | +| `T01-PLAN.md` | Individual task plan with verification criteria | +| `T01-SUMMARY.md` | What happened — YAML frontmatter + narrative | +| `S01-UAT.md` | Human test script derived from slice outcomes | + +### Git Strategy + +Branch-per-slice with squash merge. Fully automated. + +``` +main: + feat(M001/S03): auth and session management + feat(M001/S02): API endpoints and middleware + feat(M001/S01): data model and type system + +gsd/M001/S01 (preserved): + feat(S01/T03): file writer with round-trip fidelity + feat(S01/T02): markdown parser for plan files + feat(S01/T01): core types and interfaces +``` + +One commit per slice on main. Per-task history preserved on branches. Git bisect works. Individual slices are revertable. + +### Verification + +Every task has must-haves — mechanically checkable outcomes: + +- **Truths** — Observable behaviors ("User can sign up with email") +- **Artifacts** — Files that must exist with real implementation, not stubs +- **Key Links** — Imports and wiring between artifacts + +The verification ladder: static checks → command execution → behavioral testing → human review (only when the agent genuinely can't verify itself). + +### Dashboard + +`Ctrl+Alt+G` or `/gsd status` opens a real-time overlay showing: + +- Current milestone, slice, and task progress +- Auto mode elapsed time and phase +- Per-unit cost and token breakdown by phase, slice, and model +- Cost projections based on completed work +- Completed and in-progress units + +--- + +## Configuration + +### Preferences + +GSD preferences live in `~/.gsd/preferences.md` (global) or `.gsd/preferences.md` (project). Manage with `/gsd prefs`. + +```yaml +--- +version: 1 +models: + research: claude-sonnet-4-6 + planning: claude-opus-4-6 + execution: claude-sonnet-4-6 + completion: claude-sonnet-4-6 +skill_discovery: suggest +auto_supervisor: + soft_timeout_minutes: 20 + idle_timeout_minutes: 10 + hard_timeout_minutes: 30 +budget_ceiling: 50.00 +--- +``` + +**Key settings:** + +| Setting | What it controls | +|---------|-----------------| +| `models.*` | Per-phase model selection (Opus for planning, Sonnet for execution, etc.) | +| `skill_discovery` | `auto` / `suggest` / `off` — how GSD finds and applies skills | +| `auto_supervisor.*` | Timeout thresholds for auto mode supervision | +| `budget_ceiling` | USD ceiling — auto mode pauses when reached | +| `uat_dispatch` | Enable automatic UAT runs after slice completion | +| `always_use_skills` | Skills to always load when relevant | +| `skill_rules` | Situational rules for skill routing | + +### Bundled Tools + +GSD ships with 11 extensions, all loaded automatically: + +| Extension | What it provides | +|-----------|-----------------| +| **GSD** | Core workflow engine, auto mode, commands, dashboard | +| **Browser Tools** | Playwright-based browser for UI verification | +| **Search the Web** | Brave Search + Jina page extraction | +| **Context7** | Up-to-date library/framework documentation | +| **Background Shell** | Long-running process management with readiness detection | +| **Subagent** | Delegated tasks with isolated context windows | +| **Plan Mode** | Structured planning before execution | +| **Slash Commands** | Custom command creation | +| **Worktree** | Git worktree management | +| **Ask User Questions** | Structured user input with single/multi-select | +| **Secure Env Collect** | Masked secret collection without manual .env editing | + +### Bundled Agents + +Three specialized subagents for delegated work: + +| Agent | Role | +|-------|------| +| **Scout** | Fast codebase recon — returns compressed context for handoff | +| **Researcher** | Web research — finds and synthesizes current information | +| **Worker** | General-purpose execution in an isolated context window | + +--- + +## Architecture + +GSD is a TypeScript application that embeds the Pi coding agent SDK. + +``` +gsd (CLI binary) + └─ loader.ts Sets PI_PACKAGE_DIR, GSD env vars, dynamic-imports cli.ts + └─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode + ├─ wizard.ts First-run API key collection (Brave/Context7/Jina) + ├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json + ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/ + └─ src/resources/ + ├─ extensions/gsd/ Core GSD extension (auto, state, commands, ...) + ├─ extensions/... 10 supporting extensions + ├─ agents/ scout, researcher, worker + ├─ AGENTS.md Agent routing instructions + └─ GSD-WORKFLOW.md Manual bootstrap protocol +``` + +**Key design decisions:** + +- **`pkg/` shim directory** — `PI_PACKAGE_DIR` points here (not project root) to avoid Pi's theme resolution collision with our `src/` directory. Contains only `piConfig` and theme assets. +- **Two-file loader pattern** — `loader.ts` sets all env vars with zero SDK imports, then dynamic-imports `cli.ts` which does static SDK imports. This ensures `PI_PACKAGE_DIR` is set before any SDK code evaluates. +- **Always-overwrite sync** — `npm update -g` takes effect immediately. Bundled extensions and agents are synced to `~/.gsd/agent/` on every launch, not just first run. +- **State lives on disk** — `.gsd/` is the source of truth. Auto mode reads it, writes it, and advances based on what it finds. No in-memory state survives across sessions. + +--- + +## Requirements + +- **Node.js** ≥ 20.6.0 +- **Anthropic API key** — handled by Pi's built-in auth flow on first launch +- **Git** — initialized automatically if missing + +Optional: +- Brave Search API key (web research) +- Context7 API key (library docs) +- Jina API key (page extraction) + +--- + +## License + +MIT + +--- + +
+ +**The original GSD showed what was possible. This version delivers it.** + +**`npm install -g gsd-pi && gsd`** + +
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9e662a5be --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3985 @@ +{ + "name": "@glittercowboy/gsd", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@glittercowboy/gsd", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "@mariozechner/pi-coding-agent": "^0.57.1", + "playwright": "^1.58.2" + }, + "bin": { + "gsd": "dist/loader.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1006.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1006.0.tgz", + "integrity": "sha512-xoReIImKWGEgI5+44ZqADIfjSQTx367d3wkH1kX8ZZNe70mUQxXDzLp1iWBk4FLjQyTnv0J0vMIvhSHVfvFxXA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-node": "^3.972.19", + "@aws-sdk/eventstream-handler-node": "^3.972.10", + "@aws-sdk/middleware-eventstream": "^3.972.7", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/middleware-websocket": "^3.972.12", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/token-providers": "3.1006.0", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/eventstream-serde-config-resolver": "^4.3.11", + "@smithy/eventstream-serde-node": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", + "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", + "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", + "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.18.tgz", + "integrity": "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-login": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", + "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.19.tgz", + "integrity": "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-ini": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", + "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.18.tgz", + "integrity": "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/token-providers": "3.1005.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1005.0.tgz", + "integrity": "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.18.tgz", + "integrity": "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.10.tgz", + "integrity": "sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.7.tgz", + "integrity": "sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", + "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.9", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.12.tgz", + "integrity": "sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-format-url": "^3.972.7", + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", + "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1006.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1006.0.tgz", + "integrity": "sha512-eCBaQI1w5PcliOdh8Y0YONOim2zNSTEK4E7gXYC4vIqiT/lzVODIFxmpc8oOBLPSANzcr9daIPPtjQ2C75dLFg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", + "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", + "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@google/genai": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", + "integrity": "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz", + "integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.2", + "@mariozechner/clipboard-darwin-universal": "0.3.2", + "@mariozechner/clipboard-darwin-x64": "0.3.2", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-x64-musl": "0.3.2", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz", + "integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz", + "integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz", + "integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/jiti": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", + "integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==", + "license": "MIT", + "dependencies": { + "std-env": "^3.10.0", + "yoctocolors": "^2.1.2" + }, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@mariozechner/pi-agent-core": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.57.1.tgz", + "integrity": "sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==", + "license": "MIT", + "dependencies": { + "@mariozechner/pi-ai": "^0.57.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-ai": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.57.1.tgz", + "integrity": "sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", + "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "1.14.1", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "proxy-agent": "^6.5.0", + "undici": "^7.19.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-coding-agent": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.57.1.tgz", + "integrity": "sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==", + "license": "MIT", + "dependencies": { + "@mariozechner/jiti": "^2.6.2", + "@mariozechner/pi-agent-core": "^0.57.1", + "@mariozechner/pi-ai": "^0.57.1", + "@mariozechner/pi-tui": "^0.57.1", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", + "diff": "^8.0.2", + "extract-zip": "^2.0.1", + "file-type": "^21.1.1", + "glob": "^13.0.1", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "marked": "^15.0.12", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "strip-ansi": "^7.1.0", + "undici": "^7.19.1", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.2" + } + }, + "node_modules/@mariozechner/pi-tui": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.57.1.tgz", + "integrity": "sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", + "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "license": "MIT" + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", + "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", + "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", + "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", + "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz", + "integrity": "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.2" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.1.tgz", + "integrity": "sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/koffi": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.1.tgz", + "integrity": "sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz", + "integrity": "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..31a2752bf --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "gsd-pi", + "version": "0.1.0", + "description": "GSD — Get Stuff Done coding agent", + "license": "BUSL-1.1", + "type": "module", + "bin": { + "gsd": "dist/loader.js" + }, + "files": [ + "dist", + "pkg", + "src/resources", + "scripts/postinstall.js", + "package.json", + "README.md" + ], + "piConfig": { + "name": "gsd", + "configDir": ".gsd" + }, + "engines": { + "node": ">=20.6.0" + }, + "scripts": { + "build": "tsc && npm run copy-themes", + "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"", + "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'", + "dev": "tsc --watch", + "postinstall": "node scripts/postinstall.js", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@mariozechner/pi-coding-agent": "^0.57.1", + "playwright": "^1.58.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.4.0" + } +} diff --git a/pkg/package.json b/pkg/package.json new file mode 100644 index 000000000..63e03be4e --- /dev/null +++ b/pkg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@glittercowboy/gsd", + "version": "0.1.0", + "piConfig": { + "name": "gsd", + "configDir": ".gsd" + } +} diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 000000000..991a5e821 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { execSync } from 'child_process' +import os from 'os' + +const args = os.platform() === 'linux' ? '--with-deps' : '' +try { + execSync(`npx playwright install chromium ${args}`, { stdio: 'inherit' }) +} catch { + // Non-fatal — browser tools will show a clear error if playwright is missing +} diff --git a/scripts/verify-s03.sh b/scripts/verify-s03.sh new file mode 100755 index 000000000..ce326b63b --- /dev/null +++ b/scripts/verify-s03.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# S03 verification — first-run optional tool key wizard + +FAIL=0 +pass() { echo " PASS: $1"; } +fail() { echo " FAIL: $1"; FAIL=1; } + +# Run node with a timeout using background kill (macOS has no GNU timeout) +run_bg() { + local secs="$1"; shift + local tmp; tmp=$(mktemp) + local exit_tmp; exit_tmp=$(mktemp) + echo "" > "$exit_tmp" + ( "$@" > "$tmp" 2>&1; echo "$?" > "$exit_tmp" ) & + local pid=$! + sleep "$secs" + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + local code; code=$(cat "$exit_tmp") + cat "$tmp" + rm -f "$tmp" "$exit_tmp" + # Return the actual exit code if the process finished, else 0 (still running = ok) + [ -n "$code" ] && return "$code" || return 0 +} + +echo "=== S03 Verification ===" +echo "" + +# ---------------------------------------------------------------- +# Check 1 — Build: dist outputs exist +# ---------------------------------------------------------------- +echo "--- Build ---" +if [ -f "dist/wizard.js" ] && [ -f "dist/cli.js" ] && [ -f "dist/loader.js" ]; then + pass "1 — dist/wizard.js, dist/cli.js, dist/loader.js exist" +else + echo " (building...)" + npm run build --silent 2>&1 + if [ -f "dist/wizard.js" ] && [ -f "dist/cli.js" ] && [ -f "dist/loader.js" ]; then + pass "1 — build succeeded" + else + fail "1 — build failed or dist files missing" + fi +fi + +echo "" +echo "--- Non-TTY optional-key warning path ---" + +# ---------------------------------------------------------------- +# Check 2 — Non-TTY with all optional keys unset → warning on stderr, no exit 1 +# Uses a clean env with only ANTHROPIC_API_KEY set so the TUI can start, +# then kills after 3s. The warning is emitted before the TUI launches. +# ---------------------------------------------------------------- +tmp2=$(mktemp) +( + env -i HOME="$HOME" PATH="$PATH" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ + node dist/loader.js < /dev/null > "$tmp2" 2>&1 + echo "$?" >> "$tmp2" +) & +pid2=$! +sleep 3 +kill "$pid2" 2>/dev/null || true +wait "$pid2" 2>/dev/null || true + +if grep -q "Warning.*optional" "$tmp2" 2>/dev/null; then + pass "2 — Non-TTY missing optional keys → stderr warning emitted" +else + fail "2 — Non-TTY missing optional keys → stderr warning emitted" + echo " Output: $(head -3 "$tmp2")" +fi + +# Check it does NOT exit 1 for missing optional keys (last line if process exited) +last_line=$(tail -1 "$tmp2") +if [ "$last_line" = "1" ]; then + fail "3 — Non-TTY missing optional keys → does NOT exit 1 (got exit 1)" +else + pass "3 — Non-TTY missing optional keys → does NOT exit 1" +fi +rm -f "$tmp2" + +echo "" +echo "--- Wizard skip when all keys present ---" + +# ---------------------------------------------------------------- +# Check 4 — All optional keys in env → wizard does not fire (no prompt text) +# ---------------------------------------------------------------- +tmp4=$(mktemp) +( + env -i HOME="$HOME" PATH="$PATH" \ + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ + BRAVE_API_KEY="test-brave" \ + CONTEXT7_API_KEY="test-ctx7" \ + JINA_API_KEY="test-jina" \ + node dist/loader.js < /dev/null > "$tmp4" 2>&1 +) & +pid4=$! +sleep 3 +kill "$pid4" 2>/dev/null || true +wait "$pid4" 2>/dev/null || true + +if grep -qiE "optional tool|Some optional|Press Enter to skip" "$tmp4" 2>/dev/null; then + fail "4 — All optional keys in env → wizard does not fire" + echo " Output contained wizard text: $(grep -iE 'optional|Press Enter' "$tmp4" | head -2)" +else + pass "4 — All optional keys in env → wizard does not fire" +fi +rm -f "$tmp4" + +echo "" +echo "--- loadStoredEnvKeys hydration ---" + +# ---------------------------------------------------------------- +# Check 5 — Structural: env var names compiled into dist/wizard.js +# ---------------------------------------------------------------- +if grep -q "BRAVE_API_KEY" dist/wizard.js && grep -q "CONTEXT7_API_KEY" dist/wizard.js && grep -q "JINA_API_KEY" dist/wizard.js; then + pass "5 — dist/wizard.js contains all three optional key env var names" +else + fail "5 — dist/wizard.js missing one or more optional key env var names" +fi + +# ---------------------------------------------------------------- +# Check 6 — loadStoredEnvKeys: stored brave key is set into process.env +# Write a test auth.json with a brave key, run loader, confirm no crash +# ---------------------------------------------------------------- +tmp_auth=$(mktemp) +cat > "$tmp_auth" <<'EOF' +{"brave":{"type":"api_key","key":"test-brave-stored"}} +EOF + +tmp6=$(mktemp) +( + env -i HOME="$HOME" PATH="$PATH" \ + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ + GSD_TEST_AUTH_PATH="$tmp_auth" \ + node -e " + import('./dist/app-paths.js').then(async (paths) => { + // Override authFilePath for test + const { AuthStorage } = await import('@mariozechner/pi-coding-agent'); + const { loadStoredEnvKeys } = await import('./dist/wizard.js'); + const auth = AuthStorage.create('$tmp_auth'); + loadStoredEnvKeys(auth); + const val = process.env.BRAVE_API_KEY; + process.stdout.write('BRAVE_API_KEY=' + (val || '') + '\n'); + process.exit(0); + }); + " > "$tmp6" 2>&1 +) || true + +if grep -q "BRAVE_API_KEY=test-brave-stored" "$tmp6" 2>/dev/null; then + pass "6 — loadStoredEnvKeys hydrates BRAVE_API_KEY from auth.json" +else + fail "6 — loadStoredEnvKeys hydrates BRAVE_API_KEY from auth.json" + echo " Output: $(cat "$tmp6")" +fi +rm -f "$tmp_auth" "$tmp6" + +echo "" +echo "=== Results ===" +if [ "$FAIL" -eq 0 ]; then + echo "All checks passed." + exit 0 +else + echo "One or more checks FAILED." + exit 1 +fi diff --git a/scripts/verify-s04.sh b/scripts/verify-s04.sh new file mode 100755 index 000000000..ad4ad6977 --- /dev/null +++ b/scripts/verify-s04.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# S04 verification — npm pack tarball install smoke test +# Checks: dist integrity, GSD_BUNDLED_EXTENSION_PATHS, prepublishOnly, +# npm pack dry-run, tarball install, binary exists, launch (no extension +# errors, "gsd" branding), ~/.pi/ untouched, non-TTY warning/no exit 1. + +set -uo pipefail + +FAIL=0 +pass() { echo " PASS: $1"; } +fail() { echo " FAIL: $1"; FAIL=1; } + +SMOKE_PREFIX=/tmp/gsd-smoke-prefix +TARBALL="" + +# Capture ~/.pi/agent/sessions/ count before any smoke runs (for Check 9) +PI_SESSIONS_BEFORE=$(ls ~/.pi/agent/sessions/ 2>/dev/null | wc -l | tr -d ' ') + +cleanup() { + rm -rf "$SMOKE_PREFIX" + if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then + rm -f "$TARBALL" + fi +} +trap cleanup EXIT + +echo "=== S04 Verification ===" +echo "" + +# ---------------------------------------------------------------- +# Check 1 — dist/loader.js exists and has NODE_PATH block +# ---------------------------------------------------------------- +echo "--- Dist integrity ---" +if [ -f "dist/loader.js" ] && grep -q "NODE_PATH" dist/loader.js; then + pass "1 — dist/loader.js exists and contains NODE_PATH block" +else + fail "1 — dist/loader.js missing or NODE_PATH block absent" +fi + +# ---------------------------------------------------------------- +# Check 2 — GSD_BUNDLED_EXTENSION_PATHS does NOT reference src/resources +# ---------------------------------------------------------------- +# The variable must be present and must use agentDir-based paths only. +paths_line=$(grep "GSD_BUNDLED_EXTENSION_PATHS" dist/loader.js | grep -v "src/resources" | head -1) +if [ -n "$paths_line" ]; then + # Double-check: none of the actual join() lines (not comments) reference src/resources. + # We look only at lines containing join( to avoid matching comment lines like "NOT src/resources". + if grep -A 15 "GSD_BUNDLED_EXTENSION_PATHS" dist/loader.js | grep "join(" | grep -q "src/resources"; then + fail "2 — GSD_BUNDLED_EXTENSION_PATHS still references src/resources path(s)" + else + pass "2 — GSD_BUNDLED_EXTENSION_PATHS uses agentDir-based paths (no src/resources)" + fi +else + fail "2 — GSD_BUNDLED_EXTENSION_PATHS line not found or still references src/resources" +fi + +echo "" +echo "--- package.json hooks ---" + +# ---------------------------------------------------------------- +# Check 3 — prepublishOnly present in package.json +# ---------------------------------------------------------------- +if node -e "const p=JSON.parse(require('fs').readFileSync('package.json','utf8')); process.exit(p.scripts?.prepublishOnly ? 0 : 1)" 2>/dev/null; then + pass "3 — prepublishOnly hook present in package.json" +else + fail "3 — prepublishOnly hook missing from package.json" +fi + +echo "" +echo "--- npm pack dry-run ---" + +# ---------------------------------------------------------------- +# Check 4 — npm pack --dry-run lists expected files +# ---------------------------------------------------------------- +dry_out=$(npm pack --dry-run 2>&1) +file_count=$(echo "$dry_out" | grep -c "npm notice" || true) +has_src=$(echo "$dry_out" | grep -q "src/resources" && echo "yes" || echo "no") +has_dist=$(echo "$dry_out" | grep -q "dist/" && echo "yes" || echo "no") +has_pkg=$(echo "$dry_out" | grep -q "pkg/" && echo "yes" || echo "no") + +# Count actual files listed (lines with a path, not summary lines) +file_lines=$(echo "$dry_out" | grep "npm notice" | grep -v "=== Tarball" | grep -v "filename\|package size\|unpacked size\|shasum\|integrity\|total files" | wc -l | tr -d ' ') + +if [ "$file_lines" -ge 100 ] && [ "$has_dist" = "yes" ] && [ "$has_pkg" = "yes" ]; then + # src/resources check — warn but don't fail if absent (it's in "files" array but may not produce 100+ files on its own) + if [ "$has_src" = "yes" ]; then + pass "4 — dry-run: ${file_lines} files listed, dist/ present, pkg/ present, src/resources present" + else + fail "4 — dry-run: ${file_lines} files listed but src/resources NOT in pack output" + echo " (dry-run output tail:)" + echo "$dry_out" | tail -10 | sed 's/^/ /' + fi +elif [ "$file_lines" -lt 100 ]; then + fail "4 — dry-run: only ${file_lines} files listed (expected >=100)" + echo "$dry_out" | tail -10 | sed 's/^/ /' +else + fail "4 — dry-run: dist/=${has_dist} pkg/=${has_pkg}" + echo "$dry_out" | tail -10 | sed 's/^/ /' +fi + +echo "" +echo "--- tarball pack ---" + +# ---------------------------------------------------------------- +# Check 5 — npm pack produces a tarball +# ---------------------------------------------------------------- +# Note: prepublishOnly triggers a build here (expected). +npm pack --silent 2>/dev/null || npm pack 2>&1 | tail -5 +TARBALL=$(ls glittercowboy-gsd-*.tgz 2>/dev/null | head -1 || true) +if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then + pass "5 — tarball produced: $TARBALL" +else + fail "5 — npm pack did not produce a tarball" + echo " Aborting remaining checks — no tarball available." + echo "" + echo "=== Results ===" + echo "One or more checks FAILED." + exit 1 +fi + +echo "" +echo "--- tarball install ---" + +# ---------------------------------------------------------------- +# Check 6 — tarball installs cleanly to temp prefix +# ---------------------------------------------------------------- +rm -rf "$SMOKE_PREFIX" +if npm install -g --prefix "$SMOKE_PREFIX" "./$TARBALL" 2>&1 | tail -5; then + pass "6 — tarball installed to $SMOKE_PREFIX (exit 0)" +else + fail "6 — tarball install failed" +fi + +# ---------------------------------------------------------------- +# Check 7 — binary exists at expected path after install +# ---------------------------------------------------------------- +if [ -f "$SMOKE_PREFIX/bin/gsd" ] || [ -L "$SMOKE_PREFIX/bin/gsd" ]; then + pass "7 — $SMOKE_PREFIX/bin/gsd exists after install" +else + fail "7 — $SMOKE_PREFIX/bin/gsd not found after install" + ls -la "$SMOKE_PREFIX/bin/" 2>/dev/null || echo " (bin/ dir does not exist)" +fi + +echo "" +echo "--- launch smoke ---" + +# ---------------------------------------------------------------- +# Check 8 — launch: "gsd" branding + zero extension load errors +# Use background kill pattern (macOS has no GNU timeout). +# Allow 8s for extensions to load. +# ---------------------------------------------------------------- +smoke_out=$(mktemp) +( + env -i HOME="$HOME" PATH="$PATH" \ + "$SMOKE_PREFIX/bin/gsd" < /dev/null > "$smoke_out" 2>&1 +) & +smoke_pid=$! +sleep 8 +kill "$smoke_pid" 2>/dev/null || true +wait "$smoke_pid" 2>/dev/null || true + +ext_errors=$(grep "Extension load error" "$smoke_out" 2>/dev/null | wc -l | tr -d ' ') +# Strip ANSI escape codes for branding check +plain_out=$(sed 's/\x1b\[[0-9;]*m//g' "$smoke_out" 2>/dev/null || cat "$smoke_out") +has_gsd=$(echo "$plain_out" | grep -qi "gsd\|get stuff done" && echo "yes" || echo "no") + +if [ "$ext_errors" -eq 0 ]; then + pass "8a — zero Extension load errors on launch" +else + fail "8a — ${ext_errors} Extension load error(s) on launch" + grep "Extension load error" "$smoke_out" | head -5 | sed 's/^/ /' +fi + +if [ "$has_gsd" = "yes" ]; then + pass "8b — \"gsd\" / \"get stuff done\" branding found in launch output" +else + # Fallback: check if binary self-identifies differently (not "pi") + has_pi_only=$(echo "$plain_out" | grep -qi "^pi\b" && echo "yes" || echo "no") + if [ "$has_pi_only" = "no" ]; then + pass "8b — output does not show \"pi\" branding (gsd branding likely in ANSI sequences)" + else + fail "8b — output shows \"pi\" branding instead of \"gsd\"" + head -5 "$smoke_out" | sed 's/^/ /' + fi +fi +rm -f "$smoke_out" + +echo "" +echo "--- ~/.pi/ isolation ---" + +# ---------------------------------------------------------------- +# Check 9 — ~/.pi/ session count unchanged before/after smoke run +# PI_SESSIONS_BEFORE captured at script start (before any binary invocation). +# ---------------------------------------------------------------- +pi_after=$(ls ~/.pi/agent/sessions/ 2>/dev/null | wc -l | tr -d ' ') +if [ "$PI_SESSIONS_BEFORE" = "$pi_after" ]; then + pass "9 — ~/.pi/agent/sessions/ count unchanged (${pi_after} sessions before and after)" +else + fail "9 — ~/.pi/agent/sessions/ count changed: was ${PI_SESSIONS_BEFORE}, now ${pi_after}" +fi + +echo "" +echo "--- non-TTY warning path ---" + +# ---------------------------------------------------------------- +# Check 10 — non-TTY missing optional keys → warning, no exit 1 +# Run installed binary with minimal env (HOME + PATH only), piped from /dev/null. +# ---------------------------------------------------------------- +tmp10=$(mktemp) +exit10_tmp=$(mktemp) +echo "" > "$exit10_tmp" +( + env -i HOME="$HOME" PATH="$PATH" \ + "$SMOKE_PREFIX/bin/gsd" < /dev/null > "$tmp10" 2>&1 + echo "$?" > "$exit10_tmp" +) & +pid10=$! +sleep 5 +kill "$pid10" 2>/dev/null || true +wait "$pid10" 2>/dev/null || true + +if grep -qi "warning\|optional" "$tmp10" 2>/dev/null; then + pass "10a — non-TTY missing optional keys → warning emitted" +else + fail "10a — non-TTY missing optional keys → no warning found in output" + echo " Output (first 5 lines):" + head -5 "$tmp10" | sed 's/^/ /' +fi + +exit10_code=$(cat "$exit10_tmp") +if [ "$exit10_code" = "1" ]; then + fail "10b — non-TTY missing optional keys → exited with code 1 (should not)" + echo " Output: $(head -3 "$tmp10")" +else + pass "10b — non-TTY missing optional keys → did NOT exit 1 (code: ${exit10_code:-killed})" +fi +rm -f "$tmp10" "$exit10_tmp" + +echo "" +echo "=== Results ===" +if [ "$FAIL" -eq 0 ]; then + echo "All checks passed." + exit 0 +else + echo "One or more checks FAILED." + exit 1 +fi diff --git a/src/app-paths.ts b/src/app-paths.ts new file mode 100644 index 000000000..94cc70a39 --- /dev/null +++ b/src/app-paths.ts @@ -0,0 +1,7 @@ +import { homedir } from 'os' +import { join } from 'path' + +export const appRoot = join(homedir(), '.gsd') +export const agentDir = join(appRoot, 'agent') +export const sessionsDir = join(appRoot, 'sessions') +export const authFilePath = join(agentDir, 'auth.json') diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 000000000..1c9dff7da --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,51 @@ +import { + AuthStorage, + ModelRegistry, + SettingsManager, + SessionManager, + createAgentSession, + InteractiveMode, +} from '@mariozechner/pi-coding-agent' +import { agentDir, sessionsDir, authFilePath } from './app-paths.js' +import { buildResourceLoader, initResources } from './resource-loader.js' +import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js' + +const authStorage = AuthStorage.create(authFilePath) +loadStoredEnvKeys(authStorage) +await runWizardIfNeeded(authStorage) + +const modelRegistry = new ModelRegistry(authStorage) +const settingsManager = SettingsManager.create(agentDir) + +// GSD always uses quiet startup — the gsd extension renders its own branded header +if (!settingsManager.getQuietStartup()) { + settingsManager.setQuietStartup(true) +} + +// Collapse changelog by default — avoid wall of text on updates +if (!settingsManager.getCollapseChangelog()) { + settingsManager.setCollapseChangelog(true) +} + +const sessionManager = SessionManager.create(process.cwd(), sessionsDir) + +initResources(agentDir) +const resourceLoader = buildResourceLoader(agentDir) +await resourceLoader.reload() + +const { session, extensionsResult } = await createAgentSession({ + authStorage, + modelRegistry, + settingsManager, + sessionManager, + resourceLoader, +}) + +if (extensionsResult.errors.length > 0) { + for (const err of extensionsResult.errors) { + process.stderr.write(`[gsd] Extension load error: ${err.error}\n`) + } +} + +const interactiveMode = new InteractiveMode(session) +await interactiveMode.run() diff --git a/src/loader.ts b/src/loader.ts new file mode 100644 index 000000000..f630a5a08 --- /dev/null +++ b/src/loader.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'url' +import { dirname, resolve, join } from 'path' +import { readFileSync } from 'fs' +import { agentDir } from './app-paths.js' + +// pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's +// theme assets (dist/modes/interactive/theme/) without a src/ directory. +// This allows config.js to: +// 1. Read piConfig.name → "gsd" (branding) +// 2. Resolve themes via dist/ (no src/ present → uses dist path) +const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg') + +// MUST be set before any dynamic import of pi SDK fires — this is what config.js +// reads to determine APP_NAME and CONFIG_DIR_NAME +process.env.PI_PACKAGE_DIR = pkgDir +process.title = 'gsd' + +// GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.pi/agent/ +process.env.GSD_CODING_AGENT_DIR = agentDir + +// NODE_PATH — make gsd's own node_modules available to extensions loaded via jiti. +// Without this, extensions (e.g. browser-tools) can't resolve dependencies like +// `playwright` because jiti resolves modules from pi-coding-agent's location, not gsd's. +// Prepending gsd's node_modules to NODE_PATH fixes this for all extensions. +const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const gsdNodeModules = join(gsdRoot, 'node_modules') +process.env.NODE_PATH = process.env.NODE_PATH + ? `${gsdNodeModules}:${process.env.NODE_PATH}` + : gsdNodeModules +// Force Node to re-evaluate module search paths with the updated NODE_PATH. +// Must happen synchronously before cli.js imports → extension loading. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { Module } = await import('module'); +(Module as any)._initPaths?.() + +// GSD_VERSION — expose package version so extensions can display it +try { + const gsdPkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8')) + process.env.GSD_VERSION = gsdPkg.version || '0.0.0' +} catch { + process.env.GSD_VERSION = '0.0.0' +} + +// GSD_BIN_PATH — absolute path to this loader (dist/loader.js), used by patched subagent +// to spawn gsd instead of pi when dispatching workflow tasks +process.env.GSD_BIN_PATH = process.argv[1] + +// GSD_WORKFLOW_PATH — absolute path to bundled GSD-WORKFLOW.md, used by patched gsd extension +// when dispatching workflow prompts (dist/loader.js → ../src/resources/GSD-WORKFLOW.md) +const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources') +process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md') + +// GSD_BUNDLED_EXTENSION_PATHS — colon-joined list of all bundled extension entry point absolute +// paths, used by patched subagent to pass --extension to spawned gsd processes. +// IMPORTANT: paths point to agentDir (~/.gsd/agent/extensions/) NOT src/resources/extensions/. +// initResources() syncs bundled extensions to agentDir before any extension loading occurs, +// so these paths are always valid at runtime. Using agentDir paths matches what buildResourceLoader +// discovers (it scans agentDir), so pi's deduplication works correctly and extensions are not +// double-loaded in subagent child processes. +// Note: shared/ is NOT included — it's a library imported by gsd and ask-user-questions, not an entry point. +process.env.GSD_BUNDLED_EXTENSION_PATHS = [ + join(agentDir, 'extensions', 'gsd', 'index.ts'), + join(agentDir, 'extensions', 'bg-shell', 'index.ts'), + join(agentDir, 'extensions', 'browser-tools', 'index.ts'), + join(agentDir, 'extensions', 'context7', 'index.ts'), + join(agentDir, 'extensions', 'search-the-web', 'index.ts'), + join(agentDir, 'extensions', 'slash-commands', 'index.ts'), + join(agentDir, 'extensions', 'subagent', 'index.ts'), + join(agentDir, 'extensions', 'worktree', 'index.ts'), + join(agentDir, 'extensions', 'plan-mode', 'index.ts'), + join(agentDir, 'extensions', 'ask-user-questions.ts'), + join(agentDir, 'extensions', 'get-secrets-from-user.ts'), +].join(':') + +// Dynamic import defers ESM evaluation — config.js will see PI_PACKAGE_DIR above +await import('./cli.js') diff --git a/src/resource-loader.ts b/src/resource-loader.ts new file mode 100644 index 000000000..7f8be8942 --- /dev/null +++ b/src/resource-loader.ts @@ -0,0 +1,54 @@ +import { DefaultResourceLoader } from '@mariozechner/pi-coding-agent' +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Resolves to the bundled src/resources/ inside the npm package at runtime: +// dist/resource-loader.js → .. → package root → src/resources/ +const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources') +const bundledExtensionsDir = join(resourcesDir, 'extensions') + +/** + * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch. + * + * - extensions/ → ~/.gsd/agent/extensions/ (always overwrite — ensures updates ship on next launch) + * - agents/ → ~/.gsd/agent/agents/ (always overwrite) + * - AGENTS.md → ~/.gsd/agent/AGENTS.md (always overwrite) + * - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var + * + * Always-overwrite ensures `npm update -g @glittercowboy/gsd` takes effect immediately. + * User customizations should go in ~/.gsd/agent/extensions/ subdirs with unique names, + * not by editing the gsd-managed files. + * + * Inspectable: `ls ~/.gsd/agent/extensions/` + */ +export function initResources(agentDir: string): void { + mkdirSync(agentDir, { recursive: true }) + + // Sync extensions — always overwrite so updates land on next launch + const destExtensions = join(agentDir, 'extensions') + cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true }) + + // Sync agents + const destAgents = join(agentDir, 'agents') + const srcAgents = join(resourcesDir, 'agents') + if (existsSync(srcAgents)) { + cpSync(srcAgents, destAgents, { recursive: true, force: true }) + } + + // Sync AGENTS.md + const srcAgentsMd = join(resourcesDir, 'AGENTS.md') + const destAgentsMd = join(agentDir, 'AGENTS.md') + if (existsSync(srcAgentsMd)) { + writeFileSync(destAgentsMd, readFileSync(srcAgentsMd)) + } +} + +/** + * Constructs a DefaultResourceLoader with no additionalExtensionPaths. + * Extensions are synced to agentDir by initResources() and pi auto-discovers + * them from ~/.gsd/agent/extensions/ via its normal agentDir scan. + */ +export function buildResourceLoader(agentDir: string): DefaultResourceLoader { + return new DefaultResourceLoader({ agentDir }) +} diff --git a/src/resources/AGENTS.md b/src/resources/AGENTS.md new file mode 100644 index 000000000..20043d294 --- /dev/null +++ b/src/resources/AGENTS.md @@ -0,0 +1,204 @@ +## Hard Rules + +- Never ask the user to do work the agent can execute or verify itself. +- Use the lightest sufficient tool first. +- Read before edit. +- Reproduce before fix when possible. +- Work is not done until the relevant verification has passed. +- Never print, echo, log, or restate secrets or credentials. Report only key names and applied/skipped status. +- Never ask the user to edit `.env` files or set secrets manually. Use `secure_env_collect`. +- For nontrivial work inside `~/.pi`, use a worktree by default. +- In enduring files, write current state only unless the file is explicitly historical. + +## Execution Heuristics + +### Tool-routing hierarchy + +Use the lightest sufficient tool first. + +- Known file path, need contents -> `read` +- Search repo text or symbols -> `bash` with `rg` +- Search by filename or path -> `bash` with `find` or `rg --files` +- Precise existing-file change -> `read` then `edit` +- New file or full rewrite -> `write` +- Broad unfamiliar subsystem mapping -> `subagent` with `scout` +- Library, package, or framework truth -> `resolve_library` then `get_library_docs` +- Current external facts -> `search-the-web`, then `fetch_page` for full page content +- Long-running or indefinite shell commands (servers, watchers, builds) -> `bg_shell` with `start` + `wait_for_ready` +- Background process status check -> `bg_shell` with `digest` (not `output`) +- Background process debugging -> `bg_shell` with `highlights`, then `output` with `filter` +- UI behavior verification -> browser tools +- Secrets -> `secure_env_collect` + +### Web research vs browser execution + +Treat these as different jobs. + +- Use `search-the-web` + `fetch_page` for current external knowledge: release notes, product changes, pricing, news, public docs, and fast-moving ecosystem facts. +- Use browser tools for interactive execution and verification: local app flows, reproducing browser bugs, DOM behavior, navigation, auth flows, and user-visible UI outcomes. +- Do not use browser tools as a substitute for web research. +- Do not use web search as a substitute for exercising a real browser flow. + +### Investigation escalation ladder + +Escalate in this order: + +1. Direct action if the target is explicit and the change is low-risk +2. Targeted search with `rg` or `find` +3. Minimal file reads +4. `scout` when direct exploration would require reading many files or building a broad mental map +5. Multi-agent chains for large, architectural, or multi-stage work + +### Ask vs infer + +Use `ask_user_questions` when the answer is intent-driven and materially affects the result. + +Ask only when the answer: + +- materially affects behavior, architecture, data shape, or user-visible outcomes +- cannot be derived from repo evidence, docs, runtime behavior, tests, browser inspection, or command output +- is needed to avoid an irreversible or high-cost mistake + +Do not ask when: + +- the answer is discoverable +- the ambiguity is minor and the next step is safe and reversible +- the user already asked for direct execution and the path is clear enough + +If multiple reasonable interpretations exist, choose the smallest safe reversible action that advances the task. + +### Context economy + +- Prefer minimum sufficient context over broad exploration. +- Do not read extra files just in case. +- Stop investigating once there is enough evidence to make a safe, testable change. +- Use `scout` to compress broad unfamiliar exploration instead of manually reading many files. +- When gathering independent facts from known files, read them in parallel when useful. + +### Code structure and abstraction + +- Build with future reuse in mind, especially for code likely to be consumed across tools, extensions, hooks, UI surfaces, or shared subsystems. +- Prefer small, composable primitives with clear responsibilities over large monolithic modules. +- Extract around real seams: parsing, normalization, validation, formatting, side-effect boundaries, transport, persistence, orchestration, and rendering. +- Separate orchestration from implementation details. High-level flows should read clearly; low-level helpers should stay focused. +- Prefer boring, standard abstractions over clever custom frameworks or one-off indirection layers. +- Do not abstract for its own sake. If the interface is unclear or the shape is still changing, keep code local until the seam stabilizes. +- When a small primitive is obviously reusable and cheap to extract, do it early rather than duplicating logic. +- Optimize for code that is easy to recombine, test, and consume later — not just code that solves the immediate task. +- Preserve local consistency with the surrounding codebase unless the task explicitly includes broader refactoring. + +### Verification and definition of done + +Verify according to task type. + +- Bug fix -> rerun the exact repro +- Script or CLI fix -> rerun the exact command +- UI or web fix -> verify in the browser and check console or network logs when relevant +- Env or secrets fix -> rerun the blocked workflow after applying secrets +- Refactor -> run tests or build plus a targeted smoke check +- File delete, move, or rename -> confirm filesystem state +- Docs or config change -> verify referenced paths, commands, and settings match reality + +If a command or workflow fails, continue the loop: inspect the error, fix it, rerun it, and repeat until it passes or a real blocker requires user input. + +### Root-cause-first debugging + +- Fix the root cause, not just the visible symptom, unless the user explicitly wants a temporary workaround. +- Prefer changes that remove the failure mode over changes that merely mask it. +- When applying a temporary mitigation, label it clearly and preserve a path to the real fix. + +## Situational Playbooks + +### Background processes + +Use `bg_shell` instead of `bash` for any command that runs indefinitely or takes a long time. + +**Starting processes:** + +- Set `type:'server'` and `ready_port:` for dev servers so readiness detection is automatic. +- Set `group:''` on related processes (e.g. frontend + backend) to manage them together. +- Use `ready_pattern:''` for processes with non-standard readiness signals. +- The tool auto-classifies commands as server/build/test/watcher/generic and applies smart defaults. + +**After starting — use `wait_for_ready` instead of polling:** + +- `wait_for_ready` blocks until the process signals readiness (pattern match or port open) or times out. +- This replaces the old pattern of `start` → `sleep` → `output` → check → repeat. One tool call instead of many. + +**Checking status — use `digest` instead of `output`:** + +- `digest` returns a structured ~30-token summary (status, ports, URLs, error count, change summary) instead of ~2000 tokens of raw output. Use this by default. +- `highlights` returns only significant lines (errors, URLs, results) — typically 5-15 lines instead of hundreds. +- `output` returns raw incremental lines — use only when debugging and you need full text. Add `filter:'error|warning'` to narrow results. +- Token budget hierarchy: `digest` (~30 tokens) < `highlights` (~100 tokens) < `output` (~2000 tokens). Always start with the lightest. + +**Lifecycle awareness:** + +- Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll for failures. +- Use `group_status` to check health of related processes as a unit. +- Use `restart` to kill and relaunch with the same config — preserves restart count. + +**Interactive processes:** + +- Use `send_and_wait` for interactive CLIs: send input and wait for an expected output pattern. Replaces manual `send` → `sleep` → `output` polling. + +**Cleanup:** + +- Kill processes when done with them — do not leave orphans. +- Use `list` to see all running background processes. + +### Web behavior + +When the task involves frontend behavior, DOM interactions, navigation, or user flows, verify with browser tools against a running app before marking the work complete. + +Use browser tools with this operating order unless there is a clear reason not to: + +1. Cheap discovery first — use `browser_find` or `browser_snapshot_refs` to locate likely targets +2. Deterministic targeting — prefer refs or explicit selectors over coordinates +3. Batch obvious sequences — if the next 2-5 browser actions are clear and low-risk, use `browser_batch` +4. Assert outcomes explicitly — prefer `browser_assert` over inferring success from prose summaries +5. Diff ambiguous outcomes — use `browser_diff` when the effect of an action is unclear +6. Inspect diagnostics only when needed — use console/network/dialog logs when assertions or diffs suggest failure +7. Escalate inspection gradually — use `browser_get_accessibility_tree` only when targeted discovery is insufficient; use `browser_get_page_source` and `browser_evaluate` as escape hatches, not defaults +8. Use screenshots as supporting evidence — do not default to screenshot-first browsing when semantic tools are sufficient + +For browser or UI work, “verified” means the flow was exercised and the expected outcome was checked explicitly with `browser_assert` or an equally structured browser signal whenever possible. + +For browser failures, debug in this order: + +1. inspect the failing assertion or explicit success signal +2. inspect `browser_diff` +3. inspect recent console/network/dialog diagnostics +4. inspect targeted element or accessibility state +5. only then escalate to broader page inspection + +Retry only with a new hypothesis. Do not thrash. + +### Libraries, packages, and frameworks + +When a task depends on a library or framework API, use Context7 before coding. + +- Call `resolve_library` first +- Choose the highest-trust, highest-benchmark match +- Call `get_library_docs` with a specific topic query +- Start with `tokens=5000` +- Increase to `10000` only if the first result lacks needed detail + +### Current external facts + +When a task involves current events, release notes, pricing, or facts likely to have changed after training, use `search-the-web` before answering. + +- Use `freshness` to scope results by recency: `day`, `week`, `month`, `year`. Auto-detection applies when the query contains recency signals like year numbers or "latest". +- Use `domain` to limit results to a specific site when you know where the answer lives (e.g., `domain: "docs.python.org"`). +- Use `fetch_page` to read the full content of promising URLs from search results. Search snippets are a table of contents — `fetch_page` gets the actual content as clean markdown. +- Start `fetch_page` with the default `maxChars` (8000). Use smaller values for quick checks, larger (up to 30000) for thorough reading. Token-conscious: prefer reading one good page over skimming five. +- The search→read pattern is: `search-the-web` to find URLs, then `fetch_page` on the most promising 1-2 results. Don't fetch everything — be selective. + +## Communication and Writing Style + +- Be direct, professional, and focused on the work. +- Skip filler, false enthusiasm, and empty agreement. +- Challenge bad patterns, unnecessary complexity, security issues, and performance problems with concrete reasoning. +- The user makes the final call. +- All plans are for the agent's own execution, not an imaginary team's. +- Avoid enterprise patterns unless the user explicitly asks for them. diff --git a/src/resources/GSD-WORKFLOW.md b/src/resources/GSD-WORKFLOW.md new file mode 100644 index 000000000..a08825abb --- /dev/null +++ b/src/resources/GSD-WORKFLOW.md @@ -0,0 +1,661 @@ +# GSD Workflow — Manual Bootstrap Protocol + +> This document teaches you how to operate the GSD planning methodology manually using files on disk. +> +> **When to read this:** At the start of any session working on GSD-managed work, or when told `read @GSD-WORKFLOW.md`. +> +> **After reading this, always read `.gsd/state.md` to find out what's next.** +> If the milestone has a `context.md`, read that too — it contains project-specific decisions, reference paths, and implementation guidance that this generic methodology doc does not. + +--- + +## Quick Start: "What's next?" + +Read these files in order and act on what they say: + +1. **`.gsd/state.md`** — Where are we? What's the next action? +2. **`.gsd/milestones//roadmap.md`** — What's the plan? Which slices are done? (state.md tells you which milestone is active) +3. **`.gsd/milestones//context.md`** — Project-specific decisions, reference paths, constraints. Read this before doing implementation work. +4. If a slice is active, read its **`plan.md`** — Which tasks exist? Which are done? +5. If a task was interrupted, check for **`continue.md`** in the active slice directory — Resume from there. + +Then do the thing `state.md` says to do next. + +--- + +## The Hierarchy + +``` +Milestone → a shippable version (4-10 slices) + Slice → one demoable vertical capability (1-7 tasks) + Task → one context-window-sized unit of work (fits in one session) +``` + +**The iron rule:** A task MUST fit in one context window. If it can't, it's two tasks. + +--- + +## File Locations + +All artifacts live in `.gsd/` at the project root: + +``` +.gsd/ + state.md # Dashboard — always read first + decisions.md # Append-only decisions register + milestones/ + M001/ + roadmap.md # Milestone plan (checkboxes = state) + context.md # Optional: user decisions from discuss phase + research.md # Optional: codebase/tech research + summary.md # Milestone rollup (updated as slices complete) + slices/ + S01/ + plan.md # Task decomposition for this slice + context.md # Optional: slice-level user decisions + research.md # Optional: slice-level research + summary.md # Slice summary (written on completion) + uat.md # Non-blocking human test script (written on completion) + continue.md # Ephemeral: resume point if interrupted + tasks/ + T01-plan.md # Individual task plan + T01-summary.md # Task summary with frontmatter +``` + +--- + +## File Format Reference + +### `roadmap.md` + +```markdown +# M001: Title of the Milestone + +**Vision:** One paragraph describing what this milestone delivers. + +**Success Criteria:** +- Observable outcome 1 +- Observable outcome 2 + +--- + +## Slices + +- [ ] **S01: Slice Title** `risk:low` `depends:[]` + > After this: what the user can demo when this slice is done. + +- [ ] **S02: Another Slice** `risk:medium` `depends:[S01]` + > After this: demo sentence. + +- [x] **S03: Completed Slice** `risk:low` `depends:[S01]` + > After this: demo sentence. +``` + +**Parsing rules:** `- [x]` = done, `- [ ]` = not done. The `risk:` and `depends:[]` tags are inline metadata parsed from the line. `depends:[]` lists slice IDs this slice requires to be complete first. + +**Boundary Map** (required section in roadmap.md): + +After the slices section, include a `## Boundary Map` that shows what each slice produces and consumes: + +```markdown +## Boundary Map + +### S01 → S02 +Produces: + types.ts → User, Session, AuthToken (interfaces) + auth.ts → generateToken(), verifyToken(), refreshToken() + +Consumes: nothing (leaf node) + +### S02 → S03 +Produces: + api/auth/login.ts → POST handler + api/auth/signup.ts → POST handler + middleware.ts → authMiddleware() + +Consumes from S01: + auth.ts → generateToken(), verifyToken() +``` + +The boundary map is a **planning artifact** — not runnable code. It: +- Forces upfront thinking about slice boundaries before implementation +- Gives downstream slices a concrete target to code against +- Enables deterministic verification that slices actually connect +- Gets updated during slice planning if new interfaces emerge + +### `plan.md` (slice-level) + +```markdown +# S01: Slice Title + +**Goal:** What this slice achieves. +**Demo:** What the user can see/do when this is done. + +## Must-Haves +- Observable outcome 1 (used for verification) +- Observable outcome 2 + +## Tasks + +- [ ] **T01: Task Title** + Description of what this task does. + +- [ ] **T02: Another Task** + Description. + +## Files Likely Touched +- path/to/file.ts +- path/to/another.ts +``` + +### `TNN-plan.md` (task-level) + +```markdown +# T01: Task Title + +**Slice:** S01 +**Milestone:** M001 + +## Goal +What this task accomplishes in one sentence. + +## Must-Haves + +### Truths +Observable behaviors that must be true when this task is done: +- "User can sign up with email and password" +- "Login returns a JWT token" + +### Artifacts +Files that must exist with real implementation (not stubs): +- `src/lib/auth.ts` — JWT helpers (min 30 lines, exports: generateToken, verifyToken) +- `src/app/api/auth/login/route.ts` — Login endpoint (exports: POST) + +### Key Links +Critical wiring between artifacts: +- `login/route.ts` → `auth.ts` via import of `generateToken` +- `middleware.ts` → `auth.ts` via import of `verifyToken` + +## Steps +1. First thing to do +2. Second thing to do +3. Third thing to do + +## Context +- Relevant prior decisions or patterns to follow +- Key files to read before starting +``` + +**Must-haves are what make verification mechanically checkable.** Truths are checked by running commands or reading output. Artifacts are checked by confirming files exist with real content. Key links are checked by confirming imports/references actually connect the pieces. + +### `state.md` + +```markdown +# GSD State + +**Active Milestone:** M001 — Title +**Active Slice:** S02 — Slice Title +**Active Task:** T01 — Task Title +**Phase:** Executing + +## Recent Decisions +- Decision 1 +- Decision 2 + +## Blockers +- None (or list blockers) + +## Next Action +Exact next thing to do. +``` + +### `context.md` (from discuss phase) + +```markdown +# S01: Slice Title — Context + +**Gathered:** 2026-03-07 +**Status:** Ready for planning + +## Implementation Decisions +- Decision on gray area 1 +- Decision on gray area 2 + +## Agent's Discretion +- Areas where the user said "you decide" + +## Deferred Ideas +- Ideas that came up but belong in other slices +``` + +### `decisions.md` (append-only register) + +```markdown +# Decisions Register + + + +| # | When | Scope | Decision | Choice | Rationale | Revisable? | +|---|------|-------|----------|--------|-----------|------------| +| D001 | M001/S01 | library | Validation library | Zod | Type inference, already in deps | No | +| D002 | M001/S01 | arch | Session storage | HTTP-only cookies | Security, SSR compat | Yes — if mobile added | +| D003 | M001/S02 | api | API versioning | URL prefix /v1 | Simple, fits scale | Yes | +| D004 | M001/S03 | convention | Error format | RFC 7807 | Standard, client-friendly | No | +| D005 | M002/S01 | arch | Session storage | JWT in Authorization header | Mobile client needs it (supersedes D002) | No | +``` + +**Rules:** +- **Append-only** — rows are never edited or removed. To reverse a decision, add a new row that supersedes it (reference the old ID). +- **#** — Sequential ID (`D001`, `D002`, ...), never reused. +- **When** — Where the decision was made: `M001`, `M001/S01`, or `M001/S01/T02`. +- **Scope** — Category tag: `arch`, `pattern`, `library`, `data`, `api`, `scope`, `convention`. +- **Revisable?** — `No`, or `Yes — trigger condition`. + +**When to read:** At the start of any planning or research phase. +**When to write:** During discussion (seed from context), during planning (structural choices), during task execution (if an architectural choice was made), and during slice completion (catch-all for missed decisions). + +--- + +## The Phases + +Work flows through these phases. Each phase produces a file. + +### Phase 1: Discuss (Optional) + +**Purpose:** Capture user decisions on gray areas before planning. +**Produces:** `context.md` at milestone or slice level. +**When to use:** When the scope has ambiguities the user should weigh in on. +**When to skip:** When the user already knows exactly what they want, or told you to just go. + +**How to do it manually:** +1. Read the roadmap to understand the scope. +2. Identify 3-5 gray areas — implementation decisions the user cares about. +3. Use `ask_user_questions` to discuss each area. +4. Write decisions to `context.md`. +5. Do NOT discuss how to implement — only what the user wants. + +### Phase 2: Research (Optional) + +**Purpose:** Scout the codebase and relevant docs before planning. +**Produces:** `research.md` at milestone or slice level. +**When to use:** When working in unfamiliar code, with unfamiliar libraries, or on complex integrations. +**When to skip:** When the codebase is familiar and the work is straightforward. + +**How to do it manually:** +1. Read `context.md` if it exists — know what decisions are locked. +2. Scout relevant code: `rg`, `find`, read key files. +3. Use `resolve_library` / `get_library_docs` if needed. +4. Write findings to `research.md` with these sections: + +```markdown +# S01: Slice Title — Research + +**Researched:** 2026-03-07 +**Domain:** Primary technology/problem domain +**Confidence:** HIGH/MEDIUM/LOW + +## Summary +2-3 paragraph executive summary. Primary recommendation. + +## Don't Hand-Roll +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +Problems that look simple but have existing solutions. + +## Common Pitfalls +### Pitfall 1: Name +**What goes wrong:** ... +**Why it happens:** ... +**How to avoid:** ... +**Warning signs:** ... + +## Relevant Code +Existing files, patterns, reusable assets, integration points. + +## Sources +- Context7: /library/id — topics fetched (HIGH confidence) +- WebSearch: finding — verified against docs (MEDIUM confidence) +``` + +The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expensive mistakes. + +### Phase 3: Plan + +**Purpose:** Decompose work into context-window-sized tasks with must-haves. +**Produces:** `plan.md` + individual `T01-plan.md` files. + +**For a milestone (roadmap):** +1. Read `context.md`, `research.md`, and `.gsd/decisions.md` if they exist. +2. Decompose the vision into 4-10 demoable vertical slices. +3. Order by risk (high-risk first to validate feasibility early). +4. Write `roadmap.md` with checkboxes, risk levels, dependencies, demo sentences. +5. **Write the boundary map** — for each slice, specify what it produces (functions, types, interfaces, endpoints) and what it consumes from upstream slices. This forces interface thinking before implementation and enables deterministic verification that slices actually connect. + +**For a slice (task decomposition):** +1. Read the slice's entry in `roadmap.md` **and its boundary map section** — know what interfaces this slice must produce and consume. +2. Read `context.md`, `research.md`, and `.gsd/decisions.md` if they exist for this slice. +3. Read summaries from dependency slices (check `depends:[]` in roadmap). +4. Verify that upstream slices' actual outputs match what the boundary map says this slice consumes. If they diverge, update the boundary map. +5. Decompose into 1-7 tasks, each fitting one context window. +6. Each task needs: title, description, steps (3-10), must-haves (observable verification criteria). +7. Must-haves should reference boundary map contracts — e.g. "exports `generateToken()` as specified in boundary map S01→S02". +8. Write `plan.md` and individual `TNN-plan.md` files. + +### Phase 4: Execute + +**Purpose:** Do the work for one task. +**Produces:** Code changes + `[DONE:n]` markers. + +**How to do it manually:** +1. Read the task's `TNN-plan.md`. +2. Read relevant summaries from prior tasks (for context on what's already built). +3. Execute each step. Mark progress with `[DONE:n]` in responses. +4. If you made an architectural, pattern, or library decision, append it to `.gsd/decisions.md`. +5. If interrupted or context is getting full, write `continue.md` (see below). + +### Phase 5: Verify + +**Purpose:** Check that the task's must-haves are actually met. +**Produces:** Pass/fail determination. + +**Verification ladder — use the strongest tier you can reach:** +1. **Static:** Files exist, exports present, wiring connected, not stubs. +2. **Command:** Tests pass, build succeeds, lint clean, blocked command works. +3. **Behavioral:** Browser flows work, API responses correct. +4. **Human:** Ask the user only when you genuinely can't verify yourself. + +**The rule:** "All steps done" is NOT verification. Check the actual outcomes. + +**Verification report format** (written into the summary or surfaced on failure): + +``` +### Observable Truths +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | User can sign up | ✓ PASS | POST /api/auth/signup returns 201 | +| 2 | Login returns JWT | ✗ FAIL | Returns 500 — missing env var | + +### Artifacts +| File | Expected | Status | Evidence | +|------|----------|--------|---------| +| src/lib/auth.ts | JWT helpers, min 30 lines | ✓ SUBSTANTIVE | 87 lines, exports generateTokens | +| src/lib/email.ts | Email sending | ✗ STUB | 8 lines, console.log instead of sending | + +### Key Links +| From | To | Via | Status | +|------|----|----|--------| +| login/route.ts | auth.ts | import generateTokens | ✓ WIRED | +| email.ts | Resend API | resend.emails.send() | ✗ NOT WIRED | + +### Anti-Patterns Found +| File | Line | Pattern | Severity | +|------|------|---------|----------| +| src/lib/email.ts | 5 | console.log stub | 🛑 Blocker | +``` + +When verification finds gaps, include a **Gaps** section with what's missing, impact, and suggested fix. + +### Phase 6: Summarize + +**Purpose:** Record what happened for downstream tasks. +**Produces:** `TNN-summary.md`, and when slice completes, `summary.md`. + +**Task summary format:** +```markdown +--- +id: T01 +parent: S01 +milestone: M001 +provides: + - What this task built (~5 items) +requires: + - slice: S00 + provides: What that prior slice built that this task used +affects: [S02, S03] +key_files: + - path/to/important/file.ts +key_decisions: + - "Decision made: reasoning" +patterns_established: + - "Pattern name and where it lives" +drill_down_paths: + - .gsd/milestones/M001/slices/S01/tasks/T01-plan.md +duration: 15min +verification_result: pass +completed_at: 2026-03-07T16:00:00Z +--- + +# T01: Task Title + +**Substantive one-liner — NOT "task complete" but what actually shipped** + +## What Happened + +Concise prose narrative of what was built, why key decisions were made, +and what matters for future work. + +## Deviations +What differed from the plan and why (or "None"). + +## Files Created/Modified +- `path/to/file.ts` — What it does +``` + +The one-liner must be substantive: "JWT auth with refresh rotation using jose" not "Authentication implemented." + +**Slice summary:** Written when all tasks in a slice complete. Compresses all task summaries. Includes `drill_down_paths` to each task summary. During slice completion, review task summaries for `key_decisions` and ensure any significant ones are captured in `.gsd/decisions.md`. + +**Milestone summary:** Updated each time a slice completes. Compresses all slice summaries. This is what gets injected into later slice planning instead of loading many individual summaries. + +### Phase 7: Advance + +**Purpose:** Mark work done and move to the next thing. + +**After a task completes:** +1. Mark the task done in `plan.md` (checkbox). +2. Check if there's a next task in the slice → execute it. +3. If slice is complete → write slice summary, mark slice done in `roadmap.md`. + +**After a slice completes:** +1. Write slice `summary.md` (compresses all task summaries). +2. Write slice `uat.md` — a non-blocking human test script derived from the slice's must-haves and demo sentence. The agent does NOT wait for UAT results. +3. Mark the slice checkbox in `roadmap.md` as `[x]`. +4. Update `state.md` with new position. +5. Update milestone `summary.md` with the completed slice's contributions. +6. Continue to next slice immediately. The user tests the UAT whenever convenient. +7. If the user reports UAT failures later, create fix tasks in the current or a new slice. +8. If all slices done → milestone complete. + +--- + +## Continue-Here Protocol + +**When to write `continue.md`:** +- You're about to lose context (compaction, session end, Ctrl+C). +- The current task isn't done yet. +- You want to pause and come back later. + +**What to capture:** +```markdown +--- +milestone: M001 +slice: S01 +task: T02 +step: 3 +total_steps: 7 +saved_at: 2026-03-07T15:30:00Z +--- + +## Completed Work +- What's already done in this task and prior tasks in the slice. + +## Remaining Work +- What steps remain, with enough detail to resume. + +## Decisions Made +- Key decisions and WHY (so next session doesn't re-debate). + +## Context +The "vibe" — what you were thinking, what's tricky, what to watch out for. + +## Next Action +The EXACT first thing to do when resuming. Not vague. Specific. +``` + +**How to resume:** +1. Read `continue.md`. +2. Delete `continue.md` (it's consumed, not permanent). +3. Pick up from "Next Action". + +--- + +## State Management + +### `state.md` is a derived cache + +It is NOT the source of truth. It's a convenience dashboard. + +**Sources of truth:** +- `roadmap.md` → which slices exist and which are done +- `plan.md` → which tasks exist within a slice +- `TNN-summary.md` → what happened during a task +- `summary.md` (slice/milestone) → compressed outcomes + +**Update `state.md`** after every significant action: +- Active milestone/slice/task +- Recent decisions (last 3-5) +- Blockers +- Next action (most important — this is what a fresh session reads first) + +### Reconciliation + +If files disagree, **pause and surface to the user**: +- Roadmap says slice done but task summaries missing → inconsistency +- Task marked done but no summary → treat as incomplete +- Continue file exists for completed task → delete continue file +- State points to nonexistent slice/task → rebuild state from files + +--- + +## Git Strategy: Branch-Per-Slice with Squash Merge + +**Principle:** Main is always clean and working. Each slice gets an isolated branch. The user never runs a git command — the agent handles everything. + +### Branch Lifecycle + +1. **Slice starts** → create branch `gsd/M001/S01` from main +2. **Per-task commits** on the branch — atomic, descriptive, bisectable +3. **Slice completes** → squash merge to main as one clean commit +4. **Branch kept** — not deleted, available for per-task history + +### What Main Looks Like + +``` +feat(M001/S03): milestone and slice discuss commands +feat(M001/S02): extension scaffold and command routing +feat(M001/S01): file I/O foundation +``` + +One commit per slice. Individually revertable. Reads like a changelog. + +### What the Branch Looks Like + +``` +gsd/M001/S01: + test(S01): round-trip tests passing + feat(S01/T03): file writer with round-trip fidelity + checkpoint(S01/T03): pre-task + feat(S01/T02): markdown parser for plan files + checkpoint(S01/T02): pre-task + feat(S01/T01): core types and interfaces + checkpoint(S01/T01): pre-task +``` + +### Commit Conventions + +| When | Format | Example | +|------|--------|---------| +| Before each task | `checkpoint(S01/T02): pre-task` | Safety net for `git reset` | +| After task verified | `feat(S01/T02): ` | The real work | +| Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task | +| Slice squash to main | `feat(M001/S01): ` | Clean one-liner on main | + +Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore` + +### Squash Merge Message + +``` +feat(M001/S01): file I/O foundation + +Agent can parse, format, load, and save all GSD file types with round-trip fidelity. + +Tasks completed: +- T01: core types and interfaces +- T02: markdown parser for plan files +- T03: file writer with round-trip fidelity +``` + +### Rollback + +| Problem | Fix | +|---------|-----| +| Bad task | `git reset --hard` to checkpoint on the branch | +| Bad slice | `git revert ` on main | +| UAT failure after merge | Fix tasks on `gsd/M001/S01-fix` branch, squash as `fix(M001/S01): ` | + +--- + +## Summary Injection for Downstream Tasks + +When planning or executing a task, load relevant prior context: + +1. Check the current slice's `depends:[]` in `roadmap.md`. +2. Load summaries from those dependency slices. +3. Start with the **highest available level** — milestone `summary.md` first. +4. Only drill down to slice/task summaries if you need specific detail. +5. Stay within **~2500 tokens** of total injected summary context. +6. If the dependency chain is too large, drop the oldest/least-relevant summaries first. + +**Aim for:** +- ~5 provides per summary +- ~10 key_files per summary +- ~5 key_decisions per summary +- ~3 patterns_established per summary + +These are soft caps — exceed them when genuinely needed, but don't let summaries become essays. + +--- + +## Project-Specific Context + +This methodology doc is generic. Project-specific guidance belongs in the milestone's `context.md`: + +- **`.gsd/milestones//context.md`** — Architecture decisions, reference file paths, per-slice doc reading guides, implementation constraints, and any project-specific protocols (worktrees, testing, etc.) + +**Always read the active milestone's `context.md` before starting implementation work.** It tells you what decisions are locked, what files to reference, and how to verify your work in this specific project. + +--- + +## Checklist for a Fresh Session + +1. Read `.gsd/state.md` — what's the next action? +2. Check for `continue.md` in the active slice — is there interrupted work? +3. If resuming: read `continue.md`, delete it, pick up from "Next Action". +4. If starting fresh: read the active slice's `plan.md`, find the next incomplete task. +5. If in a planning or research phase, read `.gsd/decisions.md` — respect existing decisions. +6. Read relevant summaries from prior tasks/slices for context. +7. Do the work. +8. Verify the must-haves. +9. Write the summary. +10. Mark done, update `state.md`, advance. +11. If context is getting full or you're done for now: write `continue.md` if mid-task, or update `state.md` with next action if between tasks. + +## When Context Gets Large + +If you sense context pressure (many files read, long execution, lots of tool output): + +1. **If mid-task:** Write `continue.md` with exact resume state. Tell the user: "Context is getting full. I've saved progress to continue.md. Start a new session and say `read @GSD-WORKFLOW.md - what's next?`" +2. **If between tasks:** Just update `state.md` with the next action. No continue file needed — the next session will read state.md and pick up the next task cleanly. +3. **Don't fight it.** The whole system is designed for this. A fresh session with the right files loaded is better than a stale session with degraded reasoning. diff --git a/src/resources/agents/researcher.md b/src/resources/agents/researcher.md new file mode 100644 index 000000000..3c34ea0e3 --- /dev/null +++ b/src/resources/agents/researcher.md @@ -0,0 +1,29 @@ +--- +name: researcher +description: Web researcher that finds and synthesizes current information using Brave Search +tools: web_search, bash +--- + +You are a web researcher. You find current, accurate information using web search and synthesize it into a clear, well-structured report. + +## Strategy + +1. Search for the topic with 2-3 targeted queries to get breadth +2. Synthesize findings into a coherent summary +3. Cite sources with URLs + +## Output format + +## Summary + +Brief 2-3 sentence overview. + +## Key Findings + +Bullet points of the most important information, each with a source URL. + +## Sources + +Numbered list of sources used with titles and URLs. + +Be factual. Do not speculate beyond what the sources say. If results conflict, note it. diff --git a/src/resources/agents/scout.md b/src/resources/agents/scout.md new file mode 100644 index 000000000..f8c484ef3 --- /dev/null +++ b/src/resources/agents/scout.md @@ -0,0 +1,56 @@ +--- +name: scout +description: Fast codebase recon that returns compressed context for handoff to other agents +tools: read, grep, find, ls, bash +--- + +You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything. + +Your output will be passed to an agent who has NOT seen the files you explored. + +Thoroughness (infer from task, default medium): + +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: + +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files + +Output format: + +## Files Retrieved + +List with exact line ranges: + +1. `path/to/file.ts` (lines 10-50) - Description of what's here +2. `path/to/other.ts` (lines 100-150) - Description +3. ... + +## Key Code + +Critical types, interfaces, or functions: + +```typescript +interface Example { + // actual code from the files +} +``` + +```typescript +function keyFunction() { + // actual implementation +} +``` + +## Architecture + +Brief explanation of how the pieces connect. + +## Start Here + +Which file to look at first and why. diff --git a/src/resources/agents/worker.md b/src/resources/agents/worker.md new file mode 100644 index 000000000..fe1aff305 --- /dev/null +++ b/src/resources/agents/worker.md @@ -0,0 +1,31 @@ +--- +name: worker +description: General-purpose subagent with full capabilities, isolated context +--- + +You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation. + +Work autonomously to complete the assigned task. Use all available tools as needed, with one important restriction: + +- Do **not** spawn subagents or act as an orchestrator unless the parent task explicitly instructs you to do so. +- If the task looks like GSD orchestration, planning, scouting, parallel dispatch, or review routing, stop and report that the caller should use the appropriate specialist agent instead (for example: `gsd-worker`, `gsd-scout`, `gsd-reviewer`, or the top-level orchestrator). +- In particular, do **not** call `gsd_scout`, `subagent`, `launch_parallel_view`, or `gsd_execute_parallel` on your own initiative. + +Output format when finished: + +## Completed + +What was done. + +## Files Changed + +- `path/to/file.ts` - what changed + +## Notes (if any) + +Anything the main agent should know. + +If handing off to another agent (e.g. reviewer), include: + +- Exact file paths changed +- Key functions/types touched (short list) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts new file mode 100644 index 000000000..4446e676c --- /dev/null +++ b/src/resources/extensions/ask-user-questions.ts @@ -0,0 +1,200 @@ +/** + * Request User Input — LLM tool for asking the user questions + * + * Thin wrapper around the shared interview-ui. The LLM presents 1-3 + * questions with 2-3 options each. Each question can be single-select (default) + * or multi-select (allowMultiple: true). A free-form "None of the above" option + * is added automatically to single-select questions. + * + * Based on: https://github.com/openai/codex (codex-rs/core/src/tools/handlers/ask_user_questions.rs) + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { + showInterviewRound, + type Question, + type QuestionOption, + type RoundResult, +} from "./shared/interview-ui.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface AskUserQuestionsDetails { + questions: Question[]; + response: RoundResult | null; + cancelled: boolean; +} + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +const OptionSchema = Type.Object({ + label: Type.String({ description: "User-facing label (1-5 words)" }), + description: Type.String({ description: "One short sentence explaining impact/tradeoff if selected" }), +}); + +const QuestionSchema = Type.Object({ + id: Type.String({ description: "Stable identifier for mapping answers (snake_case)" }), + header: Type.String({ description: "Short header label shown in the UI (12 or fewer chars)" }), + question: Type.String({ description: "Single-sentence prompt shown to the user" }), + options: Type.Array(OptionSchema, { + description: + 'Provide 2-3 mutually exclusive choices for single-select, or any number for multi-select. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select; the client adds a free-form "None of the above" option automatically.', + }), + allowMultiple: Type.Optional( + Type.Boolean({ + description: + "If true, the user can select multiple options using SPACE to toggle and ENTER to confirm. No 'None of the above' option is added. Default: false.", + }), + ), +}); + +const AskUserQuestionsParams = Type.Object({ + questions: Type.Array(QuestionSchema, { + description: "Questions to show the user. Prefer 1 and do not exceed 3.", + }), +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const OTHER_OPTION_LABEL = "None of the above"; + +function errorResult( + message: string, + questions: Question[] = [], +): { content: { type: "text"; text: string }[]; details: AskUserQuestionsDetails } { + return { + content: [{ type: "text", text: message }], + details: { questions, response: null, cancelled: true }, + }; +} + +/** Convert the shared RoundResult into the JSON the LLM expects. */ +function formatForLLM(result: RoundResult): string { + const answers: Record = {}; + for (const [id, answer] of Object.entries(result.answers)) { + const list: string[] = []; + if (Array.isArray(answer.selected)) { + list.push(...answer.selected); + } else { + list.push(answer.selected); + } + if (answer.notes) { + list.push(`user_note: ${answer.notes}`); + } + answers[id] = { answers: list }; + } + return JSON.stringify({ answers }); +} + +// ─── Extension ──────────────────────────────────────────────────────────────── + +export default function AskUserQuestions(pi: ExtensionAPI) { + pi.registerTool({ + name: "ask_user_questions", + label: "Request User Input", + description: + "Request user input for one to three short questions and wait for the response. Single-select questions have 2-3 mutually exclusive options with a free-form 'None of the above' added automatically. Multi-select questions (allowMultiple: true) let the user toggle multiple options with SPACE and confirm with ENTER.", + promptGuidelines: [ + "Use ask_user_questions when you need the user to choose between concrete alternatives before proceeding.", + "Keep questions to 1 when possible; never exceed 3.", + "For single-select: each question must have 2-3 options. Put the recommended option first with '(Recommended)' suffix. Do not include an 'Other' or 'None of the above' option - the client adds one automatically.", + "For multi-select: set allowMultiple: true. The user can pick any number of options. No 'None of the above' is added.", + ], + parameters: AskUserQuestionsParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + // Validation + if (params.questions.length === 0 || params.questions.length > 3) { + return errorResult("Error: questions must contain 1-3 items", params.questions); + } + + for (const q of params.questions) { + if (!q.options || q.options.length === 0) { + return errorResult( + `Error: ask_user_questions requires non-empty options for every question (question "${q.id}" has none)`, + params.questions, + ); + } + } + + if (!ctx.hasUI) { + return errorResult("Error: UI not available (non-interactive mode)", params.questions); + } + + // Delegate to shared interview UI + const result = await showInterviewRound(params.questions, {}, ctx); + + // Check if cancelled (empty answers = user exited) + const hasAnswers = Object.keys(result.answers).length > 0; + if (!hasAnswers) { + return { + content: [{ type: "text", text: "ask_user_questions was cancelled before receiving a response" }], + details: { questions: params.questions, response: null, cancelled: true } as AskUserQuestionsDetails, + }; + } + + return { + content: [{ type: "text", text: formatForLLM(result) }], + details: { questions: params.questions, response: result, cancelled: false } as AskUserQuestionsDetails, + }; + }, + + // ─── Rendering ──────────────────────────────────────────────────────── + + renderCall(args, theme) { + const qs = (args.questions as Question[]) || []; + let text = theme.fg("toolTitle", theme.bold("ask_user_questions ")); + text += theme.fg("muted", `${qs.length} question${qs.length !== 1 ? "s" : ""}`); + if (qs.length > 0) { + const headers = qs.map((q) => q.header).join(", "); + text += theme.fg("dim", ` (${headers})`); + } + for (const q of qs) { + const multiSel = !!q.allowMultiple; + text += `\n ${theme.fg("text", q.question)}`; + const optLabels = multiSel + ? (q.options || []).map((o: QuestionOption) => o.label) + : [...(q.options || []).map((o: QuestionOption) => o.label), OTHER_OPTION_LABEL]; + const prefix = multiSel ? "☐" : ""; + const numbered = optLabels.map((l, i) => `${prefix}${i + 1}. ${l}`).join(", "); + text += `\n ${theme.fg("dim", numbered)}`; + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as AskUserQuestionsDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.cancelled || !details.response) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + + const lines: string[] = []; + for (const q of details.questions) { + const answer = details.response.answers[q.id]; + if (!answer) { + lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); + continue; + } + const selected = answer.selected; + const notes = answer.notes; + const multiSel = !!q.allowMultiple; + const answerText = multiSel && Array.isArray(selected) + ? selected.join(", ") + : (Array.isArray(selected) ? selected[0] : selected) ?? "(no answer)"; + let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`; + if (notes) { + line += ` ${theme.fg("muted", `[note: ${notes}]`)}`; + } + lines.push(line); + } + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts new file mode 100644 index 000000000..a83292951 --- /dev/null +++ b/src/resources/extensions/bg-shell/index.ts @@ -0,0 +1,2758 @@ +/** + * Background Shell Extension v2 + * + * A next-generation background process manager designed for agentic workflows. + * Provides intelligent process lifecycle management, structured output digests, + * event-driven readiness detection, and context-efficient communication. + * + * Key capabilities: + * - Multi-tier output: digest (30 tokens) → highlights → raw (full context) + * - Readiness detection: port probing, pattern matching, auto-classification + * - Process lifecycle events: starting → ready → error → exited + * - Output diffing & dedup: detect novel errors vs. repeated noise + * - Process groups: manage related processes as a unit + * - Cross-session persistence: survive context resets + * - Expect-style interactions: send_and_wait for interactive CLIs + * - Context injection: proactive alerts for crashes and state changes + * + * Tools: + * bg_shell — start, output, digest, wait_for_ready, send, send_and_wait, + * signal, list, kill, restart, group_status + * + * Commands: + * /bg — interactive process manager overlay + */ + +import { StringEnum } from "@mariozechner/pi-ai"; +import type { + ExtensionAPI, + ExtensionContext, + Theme, +} from "@mariozechner/pi-coding-agent"; +import { + truncateHead, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, +} from "@mariozechner/pi-coding-agent"; +import { + Text, + truncateToWidth, + visibleWidth, + matchesKey, + Key, +} from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createConnection } from "node:net"; +import { randomUUID } from "node:crypto"; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +// ── Types ────────────────────────────────────────────────────────────────── + +type ProcessStatus = + | "starting" + | "ready" + | "error" + | "exited" + | "crashed"; + +type ProcessType = "server" | "build" | "test" | "watcher" | "generic"; + +interface ProcessEvent { + type: + | "started" + | "ready" + | "error_detected" + | "recovered" + | "exited" + | "crashed" + | "output" + | "port_open" + | "pattern_match"; + timestamp: number; + detail: string; + data?: Record; +} + +interface OutputDigest { + status: ProcessStatus; + uptime: string; + errors: string[]; + warnings: string[]; + urls: string[]; + ports: number[]; + lastActivity: string; + outputLines: number; + changeSummary: string; +} + +interface OutputLine { + stream: "stdout" | "stderr"; + line: string; + ts: number; +} + +interface BgProcess { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + proc: ChildProcess; + /** Unified chronologically-interleaved output buffer */ + output: OutputLine[]; + exitCode: number | null; + signal: string | null; + alive: boolean; + /** Tracks how many lines in the unified output buffer the LLM has already seen */ + lastReadIndex: number; + /** Process classification */ + processType: ProcessType; + /** Current lifecycle status */ + status: ProcessStatus; + /** Detected ports */ + ports: number[]; + /** Detected URLs */ + urls: string[]; + /** Accumulated errors since last read */ + recentErrors: string[]; + /** Accumulated warnings since last read */ + recentWarnings: string[]; + /** Lifecycle events log */ + events: ProcessEvent[]; + /** Ready pattern (regex string) */ + readyPattern: string | null; + /** Ready port to probe */ + readyPort: number | null; + /** Whether readiness was ever achieved */ + wasReady: boolean; + /** Group membership */ + group: string | null; + /** Last error count snapshot for diff detection */ + lastErrorCount: number; + /** Last warning count snapshot for diff detection */ + lastWarningCount: number; + /** Dedup tracker: hash → count of repeated lines */ + lineDedup: Map; + /** Total raw lines (before dedup) for token savings calc */ + totalRawLines: number; + /** Env snapshot (keys only, no values for security) */ + envKeys: string[]; + /** Restart count */ + restartCount: number; + /** Original start config for restart */ + startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null }; +} + +interface BgProcessInfo { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + alive: boolean; + exitCode: number | null; + signal: string | null; + outputLines: number; + stdoutLines: number; + stderrLines: number; + status: ProcessStatus; + processType: ProcessType; + ports: number[]; + urls: string[]; + group: string | null; + restartCount: number; + uptime: string; + recentErrorCount: number; + recentWarningCount: number; + eventCount: number; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const MAX_BUFFER_LINES = 5000; +const MAX_EVENTS = 200; +const DEAD_PROCESS_TTL = 10 * 60 * 1000; +const PORT_PROBE_TIMEOUT = 500; +const READY_POLL_INTERVAL = 250; +const DEFAULT_READY_TIMEOUT = 30000; + +// ── Pattern Databases ────────────────────────────────────────────────────── + +/** Patterns that indicate a process is ready/listening */ +const READINESS_PATTERNS: RegExp[] = [ + // Node/JS servers + /listening\s+on\s+(?:port\s+)?(\d+)/i, + /server\s+(?:is\s+)?(?:running|started|listening)\s+(?:at|on)\s+/i, + /ready\s+(?:in|on|at)\s+/i, + /started\s+(?:server\s+)?on\s+/i, + // Next.js / Vite / etc + /Local:\s*https?:\/\//i, + /➜\s+Local:\s*/i, + /compiled\s+(?:successfully|client\s+and\s+server)/i, + // Python + /running\s+on\s+https?:\/\//i, + /Uvicorn\s+running/i, + /Development\s+server\s+is\s+running/i, + // Generic + /press\s+ctrl[\-+]c\s+to\s+(?:quit|stop)/i, + /watching\s+for\s+(?:file\s+)?changes/i, + /build\s+(?:completed|succeeded|finished)/i, +]; + +/** Patterns that indicate errors */ +const ERROR_PATTERNS: RegExp[] = [ + /\berror\b[\s:[\](]/i, + /\bERROR\b/, + /\bfailed\b/i, + /\bFAILED\b/, + /\bfatal\b/i, + /\bFATAL\b/, + /\bexception\b/i, + /\bpanic\b/i, + /\bsegmentation\s+fault\b/i, + /\bsyntax\s*error\b/i, + /\btype\s*error\b/i, + /\breference\s*error\b/i, + /Cannot\s+find\s+module/i, + /Module\s+not\s+found/i, + /ENOENT/, + /EACCES/, + /EADDRINUSE/, + /TS\d{4,5}:/, // TypeScript errors + /E\d{4,5}:/, // Rust errors + /\[ERROR\]/, + /✖|✗|❌/, // Common error symbols +]; + +/** Patterns that indicate warnings */ +const WARNING_PATTERNS: RegExp[] = [ + /\bwarning\b[\s:[\](]/i, + /\bWARN(?:ING)?\b/, + /\bdeprecated\b/i, + /\bDEPRECATED\b/, + /⚠️?/, + /\[WARN\]/, +]; + +/** Patterns to extract URLs */ +const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi; + +/** Patterns to extract port numbers from "listening" messages */ +const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi; + +/** Patterns indicating test results */ +const TEST_RESULT_PATTERNS: RegExp[] = [ + /(\d+)\s+(?:tests?\s+)?passed/i, + /(\d+)\s+(?:tests?\s+)?failed/i, + /Tests?:\s+(\d+)\s+passed/i, + /(\d+)\s+passing/i, + /(\d+)\s+failing/i, + /PASS|FAIL/, +]; + +/** Patterns indicating build completion */ +const BUILD_COMPLETE_PATTERNS: RegExp[] = [ + /build\s+(?:completed|succeeded|finished|done)/i, + /compiled\s+(?:successfully|with\s+\d+\s+(?:error|warning))/i, + /✓\s+Built/i, + /webpack\s+\d+\.\d+/i, + /bundle\s+(?:is\s+)?ready/i, +]; + +// ── Process Registry ─────────────────────────────────────────────────────── + +const processes = new Map(); + +/** Pending alerts to inject into the next agent context */ +let pendingAlerts: string[] = []; + +function addOutputLine(bg: BgProcess, stream: "stdout" | "stderr", line: string): void { + bg.output.push({ stream, line, ts: Date.now() }); + if (bg.output.length > MAX_BUFFER_LINES) { + const excess = bg.output.length - MAX_BUFFER_LINES; + bg.output.splice(0, excess); + // Adjust the read cursor so incremental delivery stays correct + bg.lastReadIndex = Math.max(0, bg.lastReadIndex - excess); + } +} + +function addEvent(bg: BgProcess, event: Omit): void { + const ev: ProcessEvent = { ...event, timestamp: Date.now() }; + bg.events.push(ev); + if (bg.events.length > MAX_EVENTS) { + bg.events.splice(0, bg.events.length - MAX_EVENTS); + } +} + +function getInfo(p: BgProcess): BgProcessInfo { + const stdoutLines = p.output.filter(l => l.stream === "stdout").length; + const stderrLines = p.output.filter(l => l.stream === "stderr").length; + return { + id: p.id, + label: p.label, + command: p.command, + cwd: p.cwd, + startedAt: p.startedAt, + alive: p.alive, + exitCode: p.exitCode, + signal: p.signal, + outputLines: p.output.length, + stdoutLines, + stderrLines, + status: p.status, + processType: p.processType, + ports: p.ports, + urls: p.urls, + group: p.group, + restartCount: p.restartCount, + uptime: formatUptime(Date.now() - p.startedAt), + recentErrorCount: p.recentErrors.length, + recentWarningCount: p.recentWarnings.length, + eventCount: p.events.length, + }; +} + +// ── Process Type Detection ───────────────────────────────────────────────── + +function detectProcessType(command: string): ProcessType { + const cmd = command.toLowerCase(); + + // Server patterns + if ( + /\b(serve|server|dev|start)\b/.test(cmd) && + /\b(npm|yarn|pnpm|bun|node|next|vite|nuxt|astro|remix|gatsby|uvicorn|flask|django|rails|cargo)\b/.test(cmd) + ) return "server"; + if (/\b(uvicorn|gunicorn|flask\s+run|manage\.py\s+runserver|rails\s+s)\b/.test(cmd)) return "server"; + if (/\b(http-server|live-server|serve)\b/.test(cmd)) return "server"; + + // Build patterns + if (/\b(build|compile|make|tsc|webpack|rollup|esbuild|swc)\b/.test(cmd)) { + if (/\b(watch|--watch|-w)\b/.test(cmd)) return "watcher"; + return "build"; + } + + // Test patterns + if (/\b(test|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|rspec)\b/.test(cmd)) return "test"; + + // Watcher patterns + if (/\b(watch|nodemon|chokidar|fswatch|inotifywait)\b/.test(cmd)) return "watcher"; + + return "generic"; +} + +// ── Output Analysis ──────────────────────────────────────────────────────── + +function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "stderr"): void { + // Error detection + if (ERROR_PATTERNS.some(p => p.test(line))) { + bg.recentErrors.push(line.trim().slice(0, 200)); // Cap line length + if (bg.recentErrors.length > 50) bg.recentErrors.splice(0, bg.recentErrors.length - 50); + + if (bg.status === "ready") { + bg.status = "error"; + addEvent(bg, { + type: "error_detected", + detail: line.trim().slice(0, 200), + data: { errorCount: bg.recentErrors.length }, + }); + pushAlert(bg, `error_detected: ${line.trim().slice(0, 120)}`); + } + } + + // Warning detection + if (WARNING_PATTERNS.some(p => p.test(line))) { + bg.recentWarnings.push(line.trim().slice(0, 200)); + if (bg.recentWarnings.length > 50) bg.recentWarnings.splice(0, bg.recentWarnings.length - 50); + } + + // URL extraction + const urlMatches = line.match(URL_PATTERN); + if (urlMatches) { + for (const url of urlMatches) { + if (!bg.urls.includes(url)) { + bg.urls.push(url); + } + } + } + + // Port extraction + let portMatch: RegExpExecArray | null; + const portRe = new RegExp(PORT_PATTERN.source, PORT_PATTERN.flags); + while ((portMatch = portRe.exec(line)) !== null) { + const port = parseInt(portMatch[1], 10); + if (port > 0 && port <= 65535 && !bg.ports.includes(port)) { + bg.ports.push(port); + addEvent(bg, { + type: "port_open", + detail: `Port ${port} detected`, + data: { port }, + }); + } + } + + // Readiness detection + if (bg.status === "starting") { + // Check custom ready pattern first + if (bg.readyPattern) { + try { + if (new RegExp(bg.readyPattern, "i").test(line)) { + transitionToReady(bg, `Custom pattern matched: ${line.trim().slice(0, 100)}`); + } + } catch { /* invalid regex, skip */ } + } + + // Check built-in readiness patterns + if (bg.status === "starting" && READINESS_PATTERNS.some(p => p.test(line))) { + transitionToReady(bg, `Readiness pattern matched: ${line.trim().slice(0, 100)}`); + } + } + + // Recovery detection: if we were in error and see a success pattern + if (bg.status === "error") { + if (READINESS_PATTERNS.some(p => p.test(line)) || BUILD_COMPLETE_PATTERNS.some(p => p.test(line))) { + bg.status = "ready"; + bg.recentErrors = []; + addEvent(bg, { type: "recovered", detail: "Process recovered from error state" }); + pushAlert(bg, "recovered — errors cleared"); + } + } + + // Dedup tracking + bg.totalRawLines++; + const lineHash = line.trim().slice(0, 100); + bg.lineDedup.set(lineHash, (bg.lineDedup.get(lineHash) || 0) + 1); +} + +function transitionToReady(bg: BgProcess, detail: string): void { + bg.status = "ready"; + bg.wasReady = true; + addEvent(bg, { type: "ready", detail }); +} + +function pushAlert(bg: BgProcess, message: string): void { + pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`); +} + +// ── Port Probing ─────────────────────────────────────────────────────────── + +function probePort(port: number, host: string = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +// ── Digest Generation ────────────────────────────────────────────────────── + +function generateDigest(bg: BgProcess, mutate: boolean = false): OutputDigest { + // Change summary: what's different since last read + const newErrors = bg.recentErrors.length - bg.lastErrorCount; + const newWarnings = bg.recentWarnings.length - bg.lastWarningCount; + const newLines = bg.output.length - bg.lastReadIndex; + + let changeSummary: string; + if (newLines === 0) { + changeSummary = "no new output"; + } else { + const parts: string[] = []; + parts.push(`${newLines} new lines`); + if (newErrors > 0) parts.push(`${newErrors} new errors`); + if (newWarnings > 0) parts.push(`${newWarnings} new warnings`); + changeSummary = parts.join(", "); + } + + // Only mutate snapshot counters when explicitly requested (e.g. from tool calls) + if (mutate) { + bg.lastErrorCount = bg.recentErrors.length; + bg.lastWarningCount = bg.recentWarnings.length; + } + + return { + status: bg.status, + uptime: formatUptime(Date.now() - bg.startedAt), + errors: bg.recentErrors.slice(-5), // Last 5 errors + warnings: bg.recentWarnings.slice(-3), // Last 3 warnings + urls: bg.urls, + ports: bg.ports, + lastActivity: bg.events.length > 0 + ? formatTimeAgo(bg.events[bg.events.length - 1].timestamp) + : "none", + outputLines: bg.output.length, + changeSummary, + }; +} + +// ── Highlight Extraction ─────────────────────────────────────────────────── + +function getHighlights(bg: BgProcess, maxLines: number = 15): string[] { + const lines: string[] = []; + + // Collect significant lines + const significant: { line: string; score: number; idx: number }[] = []; + for (let i = 0; i < bg.output.length; i++) { + const entry = bg.output[i]; + let score = 0; + if (ERROR_PATTERNS.some(p => p.test(entry.line))) score += 10; + if (WARNING_PATTERNS.some(p => p.test(entry.line))) score += 5; + if (URL_PATTERN.test(entry.line)) score += 3; + if (READINESS_PATTERNS.some(p => p.test(entry.line))) score += 8; + if (TEST_RESULT_PATTERNS.some(p => p.test(entry.line))) score += 7; + if (BUILD_COMPLETE_PATTERNS.some(p => p.test(entry.line))) score += 6; + // Boost recent lines so highlights favor fresh output over stale + if (i >= bg.output.length - 50) score += 2; + if (score > 0) { + significant.push({ line: entry.line.trim().slice(0, 300), score, idx: i }); + } + } + + // Sort by significance (tie-break by recency) + significant.sort((a, b) => b.score - a.score || b.idx - a.idx); + const top = significant.slice(0, maxLines); + + if (top.length === 0) { + // If nothing significant, show last few lines + const tail = bg.output.slice(-5); + for (const l of tail) lines.push(l.line.trim().slice(0, 300)); + } else { + for (const entry of top) lines.push(entry.line); + } + + return lines; +} + +// ── Process Start ────────────────────────────────────────────────────────── + +interface StartOptions { + command: string; + cwd: string; + label?: string; + type?: ProcessType; + readyPattern?: string; + readyPort?: number; + group?: string; + env?: Record; +} + +function startProcess(opts: StartOptions): BgProcess { + const id = randomUUID().slice(0, 8); + const processType = opts.type || detectProcessType(opts.command); + + const env = { ...process.env, ...(opts.env || {}) }; + + const proc = spawn("bash", ["-c", opts.command], { + cwd: opts.cwd, + stdio: ["pipe", "pipe", "pipe"], + env, + detached: true, + }); + + const bg: BgProcess = { + id, + label: opts.label || opts.command.slice(0, 60), + command: opts.command, + cwd: opts.cwd, + startedAt: Date.now(), + proc, + output: [], + exitCode: null, + signal: null, + alive: true, + lastReadIndex: 0, + processType, + status: "starting", + ports: [], + urls: [], + recentErrors: [], + recentWarnings: [], + events: [], + readyPattern: opts.readyPattern || null, + readyPort: opts.readyPort || null, + wasReady: false, + group: opts.group || null, + lastErrorCount: 0, + lastWarningCount: 0, + lineDedup: new Map(), + totalRawLines: 0, + envKeys: Object.keys(opts.env || {}), + restartCount: 0, + startConfig: { + command: opts.command, + cwd: opts.cwd, + label: opts.label || opts.command.slice(0, 60), + processType, + readyPattern: opts.readyPattern || null, + readyPort: opts.readyPort || null, + group: opts.group || null, + }, + }; + + addEvent(bg, { type: "started", detail: `Process started: ${opts.command.slice(0, 100)}` }); + + proc.stdout?.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n"); + for (const line of lines) { + if (line.length > 0) { + addOutputLine(bg, "stdout", line); + analyzeLine(bg, line, "stdout"); + } + } + }); + + proc.stderr?.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n"); + for (const line of lines) { + if (line.length > 0) { + addOutputLine(bg, "stderr", line); + analyzeLine(bg, line, "stderr"); + } + } + }); + + proc.on("exit", (code, sig) => { + bg.alive = false; + bg.exitCode = code; + bg.signal = sig ?? null; + + if (code === 0) { + bg.status = "exited"; + addEvent(bg, { type: "exited", detail: `Exited cleanly (code 0)` }); + } else { + bg.status = "crashed"; + const lastErrors = bg.recentErrors.slice(-3).join("; "); + const detail = `Crashed with code ${code}${sig ? ` (signal ${sig})` : ""}${lastErrors ? ` — ${lastErrors}` : ""}`; + addEvent(bg, { + type: "crashed", + detail, + data: { exitCode: code, signal: sig, lastErrors: bg.recentErrors.slice(-5) }, + }); + pushAlert(bg, `CRASHED (code ${code})${lastErrors ? `: ${lastErrors.slice(0, 120)}` : ""}`); + } + }); + + proc.on("error", (err) => { + bg.alive = false; + bg.status = "crashed"; + addOutputLine(bg, "stderr", `[spawn error] ${err.message}`); + addEvent(bg, { type: "crashed", detail: `Spawn error: ${err.message}` }); + pushAlert(bg, `spawn error: ${err.message}`); + }); + + // Port probing for server-type processes + if (bg.readyPort) { + startPortProbing(bg, bg.readyPort); + } + + processes.set(id, bg); + return bg; +} + +// ── Port Probing Loop ────────────────────────────────────────────────────── + +function startPortProbing(bg: BgProcess, port: number): void { + const interval = setInterval(async () => { + if (!bg.alive || bg.status !== "starting") { + clearInterval(interval); + return; + } + const open = await probePort(port); + if (open) { + clearInterval(interval); + if (!bg.ports.includes(port)) bg.ports.push(port); + transitionToReady(bg, `Port ${port} is open`); + addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port } }); + } + }, READY_POLL_INTERVAL); + + // Stop probing after timeout + setTimeout(() => clearInterval(interval), DEFAULT_READY_TIMEOUT); +} + +// ── Process Kill ─────────────────────────────────────────────────────────── + +function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean { + const bg = processes.get(id); + if (!bg) return false; + if (!bg.alive) return true; + try { + if (bg.proc.pid) { + try { + process.kill(-bg.proc.pid, sig); + } catch { + bg.proc.kill(sig); + } + } else { + bg.proc.kill(sig); + } + return true; + } catch { + return false; + } +} + +// ── Process Restart ──────────────────────────────────────────────────────── + +async function restartProcess(id: string): Promise { + const old = processes.get(id); + if (!old) return null; + + const config = old.startConfig; + const restartCount = old.restartCount + 1; + + // Kill old process + if (old.alive) { + killProcess(id, "SIGTERM"); + await new Promise(r => setTimeout(r, 300)); + if (old.alive) { + killProcess(id, "SIGKILL"); + await new Promise(r => setTimeout(r, 200)); + } + } + processes.delete(id); + + // Start new one + const newBg = startProcess({ + command: config.command, + cwd: config.cwd, + label: config.label, + type: config.processType, + readyPattern: config.readyPattern || undefined, + readyPort: config.readyPort || undefined, + group: config.group || undefined, + }); + newBg.restartCount = restartCount; + + return newBg; +} + +// ── Output Retrieval (multi-tier) ────────────────────────────────────────── + +interface GetOutputOptions { + stream: "stdout" | "stderr" | "both"; + tail?: number; + filter?: string; + incremental?: boolean; +} + +function getOutput(bg: BgProcess, opts: GetOutputOptions): string { + const { stream, tail, filter, incremental } = opts; + + // Get the relevant slice of the unified buffer (already in chronological order) + let entries: OutputLine[]; + if (incremental) { + entries = bg.output.slice(bg.lastReadIndex); + bg.lastReadIndex = bg.output.length; + } else { + entries = [...bg.output]; + } + + // Filter by stream if requested + if (stream !== "both") { + entries = entries.filter(e => e.stream === stream); + } + + // Apply regex filter + if (filter) { + try { + const re = new RegExp(filter, "i"); + entries = entries.filter(e => re.test(e.line)); + } catch { /* invalid regex */ } + } + + // Tail + if (tail && tail > 0 && entries.length > tail) { + entries = entries.slice(-tail); + } + + const lines = entries.map(e => e.line); + const raw = lines.join("\n"); + const truncation = truncateHead(raw, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + if (truncation.truncated) { + result += `\n\n[Output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines]`; + } + return result; +} + +// ── Wait for Ready ───────────────────────────────────────────────────────── + +async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal): Promise<{ ready: boolean; detail: string }> { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (signal?.aborted) { + return { ready: false, detail: "Cancelled" }; + } + if (!bg.alive) { + return { + ready: false, + detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}`, + }; + } + if (bg.status === "ready") { + return { + ready: true, + detail: bg.events.find(e => e.type === "ready")?.detail || "Process is ready", + }; + } + await new Promise(r => setTimeout(r, READY_POLL_INTERVAL)); + } + + // Timeout — try port probe as last resort + if (bg.readyPort) { + const open = await probePort(bg.readyPort); + if (open) { + transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`); + return { ready: true, detail: `Port ${bg.readyPort} is open` }; + } + } + + return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal` }; +} + +// ── Send and Wait ────────────────────────────────────────────────────────── + +async function sendAndWait( + bg: BgProcess, + input: string, + waitPattern: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ matched: boolean; output: string }> { + // Snapshot the current position in the unified buffer before sending + const startIndex = bg.output.length; + bg.proc.stdin?.write(input + "\n"); + + let re: RegExp; + try { + re = new RegExp(waitPattern, "i"); + } catch { + return { matched: false, output: "Invalid wait pattern regex" }; + } + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) { + const newEntries = bg.output.slice(startIndex); + return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(cancelled)" }; + } + const newEntries = bg.output.slice(startIndex); + for (const entry of newEntries) { + if (re.test(entry.line)) { + return { matched: true, output: newEntries.map(e => e.line).join("\n") }; + } + } + await new Promise(r => setTimeout(r, 100)); + } + + const newEntries = bg.output.slice(startIndex); + return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" }; +} + +// ── Group Operations ─────────────────────────────────────────────────────── + +function getGroupProcesses(group: string): BgProcess[] { + return Array.from(processes.values()).filter(p => p.group === group); +} + +function getGroupStatus(group: string): { + group: string; + healthy: boolean; + processes: { id: string; label: string; status: ProcessStatus; alive: boolean }[]; +} { + const procs = getGroupProcesses(group); + const healthy = procs.length > 0 && procs.every(p => p.alive && (p.status === "ready" || p.status === "starting")); + return { + group, + healthy, + processes: procs.map(p => ({ + id: p.id, + label: p.label, + status: p.status, + alive: p.alive, + })), + }; +} + +// ── Persistence ──────────────────────────────────────────────────────────── + +interface ProcessManifest { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + processType: ProcessType; + group: string | null; + readyPattern: string | null; + readyPort: number | null; + pid: number | undefined; +} + +function getManifestPath(cwd: string): string { + const dir = join(cwd, ".bg-shell"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return join(dir, "manifest.json"); +} + +function persistManifest(cwd: string): void { + try { + const manifest: ProcessManifest[] = Array.from(processes.values()) + .filter(p => p.alive) + .map(p => ({ + id: p.id, + label: p.label, + command: p.command, + cwd: p.cwd, + startedAt: p.startedAt, + processType: p.processType, + group: p.group, + readyPattern: p.readyPattern, + readyPort: p.readyPort, + pid: p.proc.pid, + })); + writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2)); + } catch { /* best effort */ } +} + +function loadManifest(cwd: string): ProcessManifest[] { + try { + const path = getManifestPath(cwd); + if (existsSync(path)) { + return JSON.parse(readFileSync(path, "utf-8")); + } + } catch { /* best effort */ } + return []; +} + +// ── Utilities ────────────────────────────────────────────────────────────── + +function formatUptime(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function formatTimeAgo(timestamp: number): string { + return formatUptime(Date.now() - timestamp) + " ago"; +} + +// ── Cleanup ──────────────────────────────────────────────────────────────── + +function pruneDeadProcesses(): void { + const now = Date.now(); + for (const [id, bg] of processes) { + if (!bg.alive && now - bg.startedAt > DEAD_PROCESS_TTL) { + processes.delete(id); + } + } +} + +function cleanupAll(): void { + for (const [id, bg] of processes) { + if (bg.alive) killProcess(id, "SIGKILL"); + } + processes.clear(); +} + +// ── Format Digest for LLM ────────────────────────────────────────────────── + +function formatDigestText(bg: BgProcess, digest: OutputDigest): string { + let text = `Process ${bg.id} (${bg.label}):\n`; + text += ` status: ${digest.status}\n`; + text += ` type: ${bg.processType}\n`; + text += ` uptime: ${digest.uptime}\n`; + + if (digest.ports.length > 0) text += ` ports: ${digest.ports.join(", ")}\n`; + if (digest.urls.length > 0) text += ` urls: ${digest.urls.join(", ")}\n`; + + text += ` output: ${digest.outputLines} lines\n`; + text += ` changes: ${digest.changeSummary}`; + + if (digest.errors.length > 0) { + text += `\n errors (${digest.errors.length}):`; + for (const err of digest.errors) { + text += `\n - ${err}`; + } + } + if (digest.warnings.length > 0) { + text += `\n warnings (${digest.warnings.length}):`; + for (const w of digest.warnings) { + text += `\n - ${w}`; + } + } + + return text; +} + +// ── Extension Entry Point ────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + let latestCtx: ExtensionContext | null = null; + + // Clean up on session shutdown + pi.on("session_shutdown", async () => { + cleanupAll(); + }); + + // ── Compaction Awareness: Survive Context Resets ─────────────────── + + /** Build a compact state summary of all alive processes for context re-injection */ + function buildProcessStateAlert(reason: string): void { + const alive = Array.from(processes.values()).filter(p => p.alive); + if (alive.length === 0) return; + + const processSummaries = alive.map(p => { + const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : ""; + const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : ""; + const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : ""; + const groupInfo = p.group ? ` [${p.group}]` : ""; + return ` - id:${p.id} "${p.label}" [${p.processType}] status:${p.status} uptime:${formatUptime(Date.now() - p.startedAt)}${portInfo}${urlInfo}${errInfo}${groupInfo}`; + }).join("\n"); + + pendingAlerts.push( + `${reason} ${alive.length} background process(es) are still running:\n${processSummaries}\nUse bg_shell digest/output/kill with these IDs.` + ); + } + + // After compaction, the LLM loses all memory of running processes. + // Queue a detailed alert so the next before_agent_start injects full state. + pi.on("session_compact", async () => { + buildProcessStateAlert("Context was compacted."); + }); + + // Tree navigation also resets the agent's context. + pi.on("session_tree", async () => { + buildProcessStateAlert("Session tree was navigated."); + }); + + // Session switch resets the agent's context. + pi.on("session_switch", async () => { + buildProcessStateAlert("Session was switched."); + }); + + // ── Context Injection: Proactive Alerts ──────────────────────────── + + pi.on("before_agent_start", async (_event, _ctx) => { + // Inject process status overview and any pending alerts + const alerts = pendingAlerts.splice(0); + const alive = Array.from(processes.values()).filter(p => p.alive); + + if (alerts.length === 0 && alive.length === 0) return; + + const parts: string[] = []; + + if (alerts.length > 0) { + parts.push(`Background process alerts:\n${alerts.map(a => ` ${a}`).join("\n")}`); + } + + if (alive.length > 0) { + const summary = alive.map(p => { + const status = p.status === "ready" ? "✓" : p.status === "error" ? "✗" : p.status === "starting" ? "⋯" : "?"; + const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : ""; + const errInfo = p.recentErrors.length > 0 ? ` (${p.recentErrors.length} errors)` : ""; + return ` ${status} ${p.id} ${p.label}${portInfo}${errInfo}`; + }).join("\n"); + parts.push(`Background processes:\n${summary}`); + } + + return { + message: { + customType: "bg-shell-status", + content: parts.join("\n\n"), + display: false, + }, + }; + }); + + // ── Session Start: Discover Surviving Processes ──────────────────── + + pi.on("session_start", async (_event, ctx) => { + latestCtx = ctx; + + // Check for surviving processes from previous session + const manifest = loadManifest(ctx.cwd); + if (manifest.length > 0) { + // Check which PIDs are still alive + const surviving: ProcessManifest[] = []; + for (const entry of manifest) { + if (entry.pid) { + try { + process.kill(entry.pid, 0); // Check if process exists + surviving.push(entry); + } catch { /* process is dead */ } + } + } + + if (surviving.length > 0) { + const summary = surviving.map(s => + ` - ${s.id}: ${s.label} (pid ${s.pid}, type: ${s.processType}${s.group ? `, group: ${s.group}` : ""})` + ).join("\n"); + + pendingAlerts.push( + `${surviving.length} background process(es) from previous session still running:\n${summary}\n Note: These processes are outside bg_shell's control. Kill them manually if needed.` + ); + } + } + }); + + // ── Tool ───────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "bg_shell", + label: "Background Shell", + description: + "Run shell commands in the background without blocking. Manages persistent background processes with intelligent lifecycle tracking. " + + "Actions: start (launch with auto-classification & readiness detection), digest (structured summary ~30 tokens vs ~2000 raw), " + + "output (raw lines with incremental delivery), wait_for_ready (block until process signals readiness), " + + "send (write stdin), send_and_wait (expect-style: send + wait for output pattern), " + + "signal (send OS signal), list (all processes with status), kill (terminate), restart (kill + relaunch), " + + "group_status (health of a process group), highlights (significant output lines only).", + + promptGuidelines: [ + "Use bg_shell to start long-running processes (servers, watchers, builds) that should not block the agent.", + "After starting a server, use 'wait_for_ready' to efficiently block until it's listening — avoids polling loops entirely.", + "Use 'digest' instead of 'output' when you just need status — it returns a structured ~30-token summary instead of ~2000 tokens of raw output.", + "Use 'highlights' to see only significant output (errors, URLs, results) — typically 5-15 lines instead of hundreds.", + "Use 'output' only when you need raw lines for debugging — add filter:'error|warning' to narrow results.", + "The 'output' action returns only new output since the last check (incremental). Repeated calls are cheap on context.", + "Set type:'server' and ready_port:3000 for dev servers so readiness detection is automatic.", + "Set group:'my-stack' on related processes to manage them together with 'group_status'.", + "Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.", + "Use 'restart' to kill and relaunch with the same config — preserves restart count.", + "Background processes are auto-classified (server/build/test/watcher) based on the command.", + "Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.", + ], + + parameters: Type.Object({ + action: StringEnum([ + "start", + "digest", + "output", + "highlights", + "wait_for_ready", + "send", + "send_and_wait", + "signal", + "list", + "kill", + "restart", + "group_status", + ] as const), + command: Type.Optional( + Type.String({ description: "Shell command to run (for start)" }), + ), + label: Type.Optional( + Type.String({ description: "Short human-readable label for the process (for start)" }), + ), + id: Type.Optional( + Type.String({ description: "Process ID (for digest, output, highlights, wait_for_ready, send, send_and_wait, signal, kill, restart)" }), + ), + stream: Type.Optional( + StringEnum(["stdout", "stderr", "both"] as const), + ), + tail: Type.Optional( + Type.Number({ description: "Number of most recent lines to return (for output). Defaults to 100." }), + ), + filter: Type.Optional( + Type.String({ description: "Regex pattern to filter output lines (for output). Case-insensitive." }), + ), + input: Type.Optional( + Type.String({ description: "Text to write to process stdin (for send, send_and_wait)" }), + ), + wait_pattern: Type.Optional( + Type.String({ description: "Regex to wait for in output (for send_and_wait)" }), + ), + signal_name: Type.Optional( + Type.String({ description: "OS signal to send, e.g. SIGINT, SIGTERM, SIGHUP (for signal)" }), + ), + timeout: Type.Optional( + Type.Number({ description: "Timeout in milliseconds (for wait_for_ready, send_and_wait). Default: 30000" }), + ), + type: Type.Optional( + StringEnum(["server", "build", "test", "watcher", "generic"] as const), + ), + ready_pattern: Type.Optional( + Type.String({ description: "Regex pattern that indicates the process is ready (for start)" }), + ), + ready_port: Type.Optional( + Type.Number({ description: "Port to probe for readiness (for start). When open, process is considered ready." }), + ), + group: Type.Optional( + Type.String({ description: "Group name for related processes (for start, group_status)" }), + ), + }), + + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + latestCtx = ctx; + + switch (params.action) { + // ── start ────────────────────────────────────────── + case "start": { + if (!params.command) { + return { + content: [{ type: "text" as const, text: "Error: 'command' is required for start" }], + isError: true, + }; + } + + const bg = startProcess({ + command: params.command, + cwd: ctx.cwd, + label: params.label, + type: params.type as ProcessType | undefined, + readyPattern: params.ready_pattern, + readyPort: params.ready_port, + group: params.group, + }); + + // Give the process a moment to potentially fail immediately + await new Promise(r => setTimeout(r, 500)); + + // Persist manifest + persistManifest(ctx.cwd); + + const info = getInfo(bg); + let text = `Started background process ${bg.id}\n`; + text += ` label: ${bg.label}\n`; + text += ` type: ${bg.processType}\n`; + text += ` status: ${bg.status}\n`; + text += ` command: ${bg.command}\n`; + text += ` cwd: ${bg.cwd}`; + + if (bg.group) text += `\n group: ${bg.group}`; + if (bg.readyPort) text += `\n ready_port: ${bg.readyPort}`; + if (bg.readyPattern) text += `\n ready_pattern: ${bg.readyPattern}`; + if (bg.ports.length > 0) text += `\n detected ports: ${bg.ports.join(", ")}`; + if (bg.urls.length > 0) text += `\n detected urls: ${bg.urls.join(", ")}`; + + if (!bg.alive) { + text += `\n exit code: ${bg.exitCode}`; + const errLines = bg.output.filter(l => l.stream === "stderr").map(l => l.line); + const errOut = errLines.join("\n").trim(); + if (errOut) text += `\n stderr:\n${errOut}`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "start", process: info }, + }; + } + + // ── digest ───────────────────────────────────────── + case "digest": { + // Can get digest for a single process or all + if (params.id) { + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + const digest = generateDigest(bg, true); + return { + content: [{ type: "text" as const, text: formatDigestText(bg, digest) }], + details: { action: "digest", process: getInfo(bg), digest }, + }; + } + + // All processes digest + const all = Array.from(processes.values()); + if (all.length === 0) { + return { + content: [{ type: "text" as const, text: "No background processes." }], + details: { action: "digest", processes: [] }, + }; + } + + const lines = all.map(bg => { + const d = generateDigest(bg, true); + const status = bg.alive + ? (bg.status === "ready" ? "✓" : bg.status === "error" ? "✗" : "⋯") + : "○"; + const portInfo = d.ports.length > 0 ? ` :${d.ports.join(",")}` : ""; + const errInfo = d.errors.length > 0 ? ` (${d.errors.length} errors)` : ""; + return `${status} ${bg.id} ${bg.label} [${bg.processType}] ${d.uptime}${portInfo}${errInfo} — ${d.changeSummary}`; + }); + + return { + content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }], + details: { action: "digest", count: all.length }, + }; + } + + // ── highlights ────────────────────────────────────── + case "highlights": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for highlights" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + const highlights = getHighlights(bg, params.tail || 15); + const info = getInfo(bg); + let text = `Highlights for ${bg.id} (${bg.label}) — ${bg.status}:\n`; + if (highlights.length === 0) { + text += "(no significant output)"; + } else { + text += highlights.join("\n"); + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "highlights", process: info, lineCount: highlights.length }, + }; + } + + // ── output ───────────────────────────────────────── + case "output": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for output" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + const stream = params.stream || "both"; + const tail = params.tail ?? 100; + const output = getOutput(bg, { + stream, + tail, + filter: params.filter, + incremental: true, + }); + const info = getInfo(bg); + + let text = `Process ${bg.id} (${bg.label})`; + text += ` — ${bg.alive ? `${bg.status}` : `exited (code ${bg.exitCode})`}`; + if (output) { + text += `\n${output}`; + } else { + text += `\n(no new output since last check)`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "output", process: info, stream, tail }, + }; + } + + // ── wait_for_ready ────────────────────────────────── + case "wait_for_ready": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for wait_for_ready" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + // Already ready? + if (bg.status === "ready") { + const digest = generateDigest(bg, true); + return { + content: [{ type: "text" as const, text: `Process ${bg.id} is already ready.\n${formatDigestText(bg, digest)}` }], + details: { action: "wait_for_ready", process: getInfo(bg), ready: true }, + }; + } + + const timeout = params.timeout || DEFAULT_READY_TIMEOUT; + const result = await waitForReady(bg, timeout, signal ?? undefined); + + const digest = generateDigest(bg, true); + let text: string; + if (result.ready) { + text = `✓ Process ${bg.id} is ready: ${result.detail}\n${formatDigestText(bg, digest)}`; + } else { + text = `✗ Process ${bg.id} not ready: ${result.detail}\n${formatDigestText(bg, digest)}`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "wait_for_ready", process: getInfo(bg), ready: result.ready, detail: result.detail }, + }; + } + + // ── send ─────────────────────────────────────────── + case "send": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for send" }], + isError: true, + }; + } + if (params.input === undefined) { + return { + content: [{ type: "text" as const, text: "Error: 'input' is required for send" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + if (!bg.alive) { + return { + content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }], + isError: true, + }; + } + + try { + bg.proc.stdin?.write(params.input + "\n"); + return { + content: [{ type: "text" as const, text: `Sent input to process ${bg.id}` }], + details: { action: "send", process: getInfo(bg) }, + }; + } catch (err) { + return { + content: [{ type: "text" as const, text: `Error writing to stdin: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + } + + // ── send_and_wait ─────────────────────────────────── + case "send_and_wait": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for send_and_wait" }], + isError: true, + }; + } + if (params.input === undefined) { + return { + content: [{ type: "text" as const, text: "Error: 'input' is required for send_and_wait" }], + isError: true, + }; + } + if (!params.wait_pattern) { + return { + content: [{ type: "text" as const, text: "Error: 'wait_pattern' is required for send_and_wait" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + if (!bg.alive) { + return { + content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }], + isError: true, + }; + } + + const timeout = params.timeout || 10000; + const result = await sendAndWait(bg, params.input, params.wait_pattern, timeout, signal ?? undefined); + + let text: string; + if (result.matched) { + text = `✓ Pattern matched for process ${bg.id}\n${result.output}`; + } else { + text = `✗ Pattern not matched (timed out after ${timeout}ms)\n${result.output}`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "send_and_wait", process: getInfo(bg), matched: result.matched }, + }; + } + + // ── signal ───────────────────────────────────────── + case "signal": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for signal" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + const sig = (params.signal_name || "SIGINT") as NodeJS.Signals; + const sent = killProcess(params.id, sig); + + return { + content: [{ type: "text" as const, text: sent ? `Sent ${sig} to process ${bg.id} (${bg.label})` : `Failed to send ${sig} to process ${bg.id}` }], + details: { action: "signal", process: getInfo(bg), signal: sig }, + }; + } + + // ── list ─────────────────────────────────────────── + case "list": { + const all = Array.from(processes.values()).map(getInfo); + + if (all.length === 0) { + return { + content: [{ type: "text" as const, text: "No background processes." }], + details: { action: "list", processes: [] }, + }; + } + + const lines = all.map(p => { + const status = p.alive + ? (p.status === "ready" ? "✓ ready" : p.status === "error" ? "✗ error" : "⋯ starting") + : `○ ${p.status} (code ${p.exitCode})`; + const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : ""; + const urlInfo = p.urls.length > 0 ? ` ${p.urls[0]}` : ""; + const groupInfo = p.group ? ` [${p.group}]` : ""; + return `${p.id} ${status} ${p.uptime} ${p.label} [${p.processType}]${portInfo}${urlInfo}${groupInfo}`; + }); + + return { + content: [{ type: "text" as const, text: `Background processes (${all.length}):\n${lines.join("\n")}` }], + details: { action: "list", processes: all }, + }; + } + + // ── kill ─────────────────────────────────────────── + case "kill": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for kill" }], + isError: true, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + const killed = killProcess(params.id, "SIGTERM"); + await new Promise(r => setTimeout(r, 300)); + if (bg.alive) { + killProcess(params.id, "SIGKILL"); + await new Promise(r => setTimeout(r, 200)); + } + + const info = getInfo(bg); + if (!bg.alive) processes.delete(params.id); + + // Update manifest + persistManifest(ctx.cwd); + + return { + content: [{ type: "text" as const, text: killed ? `Killed process ${bg.id} (${bg.label})` : `Failed to kill process ${bg.id}` }], + details: { action: "kill", process: info }, + }; + } + + // ── restart ──────────────────────────────────────── + case "restart": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for restart" }], + isError: true, + }; + } + + const newBg = await restartProcess(params.id); + if (!newBg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, + }; + } + + // Give it a moment + await new Promise(r => setTimeout(r, 500)); + persistManifest(ctx.cwd); + + const info = getInfo(newBg); + let text = `Restarted process (restart #${newBg.restartCount})\n`; + text += ` new id: ${newBg.id}\n`; + text += ` label: ${newBg.label}\n`; + text += ` type: ${newBg.processType}\n`; + text += ` status: ${newBg.status}\n`; + text += ` command: ${newBg.command}`; + + return { + content: [{ type: "text" as const, text }], + details: { action: "restart", process: info, previousId: params.id }, + }; + } + + // ── group_status ──────────────────────────────────── + case "group_status": { + if (!params.group) { + // List all groups + const groups = new Set(); + for (const p of processes.values()) { + if (p.group) groups.add(p.group); + } + + if (groups.size === 0) { + return { + content: [{ type: "text" as const, text: "No process groups defined." }], + details: { action: "group_status", groups: [] }, + }; + } + + const statuses = Array.from(groups).map(g => { + const gs = getGroupStatus(g); + const icon = gs.healthy ? "✓" : "✗"; + const procs = gs.processes.map(p => `${p.id} (${p.status})`).join(", "); + return `${icon} ${g}: ${procs}`; + }); + + return { + content: [{ type: "text" as const, text: `Process groups:\n${statuses.join("\n")}` }], + details: { action: "group_status", groups: Array.from(groups) }, + }; + } + + const gs = getGroupStatus(params.group); + const icon = gs.healthy ? "✓" : "✗"; + let text = `${icon} Group '${params.group}' — ${gs.healthy ? "healthy" : "unhealthy"}\n`; + for (const p of gs.processes) { + text += ` ${p.id}: ${p.label} — ${p.status}${p.alive ? "" : " (dead)"}\n`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "group_status", groupStatus: gs }, + }; + } + + default: + return { + content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], + isError: true, + }; + } + }, + + // ── Rendering ──────────────────────────────────────────────────── + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("bg_shell ")); + text += theme.fg("accent", args.action); + if (args.command) text += " " + theme.fg("muted", `$ ${args.command}`); + if (args.id) text += " " + theme.fg("dim", `[${args.id}]`); + if (args.label) text += " " + theme.fg("dim", `(${args.label})`); + if (args.type) text += " " + theme.fg("dim", `type:${args.type}`); + if (args.ready_port) text += " " + theme.fg("dim", `port:${args.ready_port}`); + if (args.group) text += " " + theme.fg("dim", `group:${args.group}`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as Record | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + const action = details.action as string; + + if (result.isError) { + const text = result.content[0]; + return new Text( + theme.fg("error", text?.type === "text" ? text.text : "Error"), + 0, 0, + ); + } + + switch (action) { + case "start": { + const proc = details.process as BgProcessInfo; + let text = theme.fg("success", "▸ Started "); + text += theme.fg("accent", proc.id); + text += " " + theme.fg("muted", proc.label); + text += " " + theme.fg("dim", `[${proc.processType}]`); + if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`); + if (!proc.alive) { + text += " " + theme.fg("error", `(exited: ${proc.exitCode})`); + } + return new Text(text, 0, 0); + } + + case "digest": { + const proc = details.process as BgProcessInfo | undefined; + if (proc) { + const statusIcon = proc.status === "ready" ? theme.fg("success", "✓") + : proc.status === "error" ? theme.fg("error", "✗") + : theme.fg("warning", "⋯"); + let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`; + if (expanded) { + const rawText = result.content[0]; + if (rawText?.type === "text") { + const lines = rawText.text.split("\n").slice(1); + for (const line of lines.slice(0, 20)) { + text += "\n " + theme.fg("dim", line); + } + } + } + return new Text(text, 0, 0); + } + return new Text(theme.fg("dim", `${details.count ?? 0} process(es)`), 0, 0); + } + + case "highlights": { + const proc = details.process as BgProcessInfo; + const lineCount = details.lineCount as number; + let text = theme.fg("accent", proc.id) + " " + theme.fg("dim", `${lineCount} highlights`); + if (expanded) { + const rawText = result.content[0]; + if (rawText?.type === "text") { + const lines = rawText.text.split("\n").slice(1); + for (const line of lines.slice(0, 20)) { + text += "\n " + theme.fg("toolOutput", line); + } + } + } + return new Text(text, 0, 0); + } + + case "output": { + const proc = details.process as BgProcessInfo; + const statusIcon = proc.alive + ? (proc.status === "ready" ? theme.fg("success", "●") : proc.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●")) + : theme.fg("error", "○"); + let text = `${statusIcon} ${theme.fg("accent", proc.id)} ${theme.fg("muted", proc.label)}`; + + if (expanded) { + const rawText = result.content[0]; + if (rawText?.type === "text") { + const lines = rawText.text.split("\n").slice(1); + const show = lines.slice(0, 30); + for (const line of show) { + text += "\n " + theme.fg("toolOutput", line); + } + if (lines.length > 30) { + text += `\n ${theme.fg("dim", `... ${lines.length - 30} more lines`)}`; + } + } + } else { + text += " " + theme.fg("dim", `(${proc.stdoutLines} stdout, ${proc.stderrLines} stderr lines)`); + } + return new Text(text, 0, 0); + } + + case "wait_for_ready": { + const proc = details.process as BgProcessInfo; + const ready = details.ready as boolean; + if (ready) { + let text = theme.fg("success", "✓ Ready ") + theme.fg("accent", proc.id); + if (proc.ports.length > 0) text += " " + theme.fg("dim", `:${proc.ports.join(",")}`); + if (proc.urls.length > 0) text += " " + theme.fg("dim", proc.urls[0]); + return new Text(text, 0, 0); + } else { + return new Text( + theme.fg("error", "✗ Not ready ") + theme.fg("accent", proc.id) + " " + theme.fg("dim", String(details.detail)), + 0, 0, + ); + } + } + + case "send": { + const proc = details.process as BgProcessInfo; + return new Text( + theme.fg("success", "→ ") + theme.fg("muted", `stdin → ${proc.id}`), + 0, 0, + ); + } + + case "send_and_wait": { + const proc = details.process as BgProcessInfo; + const matched = details.matched as boolean; + if (matched) { + return new Text( + theme.fg("success", "✓ ") + theme.fg("muted", `Pattern matched — ${proc.id}`), + 0, 0, + ); + } + return new Text( + theme.fg("warning", "✗ ") + theme.fg("muted", `Timed out — ${proc.id}`), + 0, 0, + ); + } + + case "signal": { + const sig = details.signal as string; + const proc = details.process as BgProcessInfo; + return new Text( + theme.fg("warning", `${sig} `) + theme.fg("muted", `→ ${proc.id}`), + 0, 0, + ); + } + + case "list": { + const procs = details.processes as BgProcessInfo[]; + if (procs.length === 0) { + return new Text(theme.fg("dim", "No background processes"), 0, 0); + } + let text = theme.fg("muted", `${procs.length} background process(es)`); + if (expanded) { + for (const p of procs) { + const statusIcon = p.alive + ? (p.status === "ready" ? theme.fg("success", "●") : p.status === "error" ? theme.fg("error", "●") : theme.fg("warning", "●")) + : theme.fg("error", "○"); + const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : ""; + text += `\n ${statusIcon} ${theme.fg("accent", p.id)} ${theme.fg("dim", p.uptime)} ${theme.fg("muted", p.label)} [${p.processType}]${portInfo}`; + } + } + return new Text(text, 0, 0); + } + + case "kill": { + const proc = details.process as BgProcessInfo; + return new Text( + theme.fg("success", "✓ Killed ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label), + 0, 0, + ); + } + + case "restart": { + const proc = details.process as BgProcessInfo; + return new Text( + theme.fg("success", "↻ Restarted ") + theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label) + " " + theme.fg("dim", `#${proc.restartCount}`), + 0, 0, + ); + } + + case "group_status": { + const gs = details.groupStatus as ReturnType | undefined; + if (gs) { + const icon = gs.healthy ? theme.fg("success", "✓") : theme.fg("error", "✗"); + return new Text( + `${icon} ${theme.fg("accent", gs.group)} — ${gs.processes.length} process(es)`, + 0, 0, + ); + } + const groups = details.groups as string[]; + return new Text(theme.fg("dim", `${groups?.length ?? 0} group(s)`), 0, 0); + } + + default: { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + } + }, + }); + + // ── Slash command: /bg ──────────────────────────────────────────────── + + pi.registerCommand("bg", { + description: "Manage background processes: /bg [list|output|kill|killall|groups] [id]", + + getArgumentCompletions: (prefix: string) => { + const subcommands = ["list", "output", "kill", "killall", "groups", "digest"]; + const parts = prefix.trim().split(/\s+/); + + if (parts.length <= 1) { + return subcommands + .filter(cmd => cmd.startsWith(parts[0] ?? "")) + .map(cmd => ({ value: cmd, label: cmd })); + } + + if (parts[0] === "output" || parts[0] === "kill" || parts[0] === "digest") { + const idPrefix = parts[1] ?? ""; + return Array.from(processes.values()) + .filter(p => p.id.startsWith(idPrefix)) + .map(p => ({ + value: `${parts[0]} ${p.id}`, + label: `${p.id} — ${p.label}`, + })); + } + + return []; + }, + + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const sub = parts[0] || "list"; + + if (sub === "list" || sub === "") { + if (processes.size === 0) { + ctx.ui.notify("No background processes.", "info"); + return; + } + + if (!ctx.hasUI) { + const lines = Array.from(processes.values()).map(p => { + const statusIcon = p.alive + ? (p.status === "ready" ? "✓" : p.status === "error" ? "✗" : "⋯") + : "○"; + const uptime = formatUptime(Date.now() - p.startedAt); + const portInfo = p.ports.length > 0 ? ` :${p.ports.join(",")}` : ""; + return `${p.id} ${statusIcon} ${p.status} ${uptime} ${p.label} [${p.processType}]${portInfo}`; + }); + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + await ctx.ui.custom( + (tui, theme, _kb, done) => { + return new BgManagerOverlay(tui, theme, () => { + done(); + refreshWidget(); + }); + }, + { + overlay: true, + overlayOptions: { + width: "60%", + minWidth: 50, + maxHeight: "70%", + anchor: "center", + }, + }, + ); + return; + } + + if (sub === "output" || sub === "digest") { + const id = parts[1]; + if (!id) { + ctx.ui.notify(`Usage: /bg ${sub} `, "error"); + return; + } + const bg = processes.get(id); + if (!bg) { + ctx.ui.notify(`No process with id '${id}'`, "error"); + return; + } + + if (!ctx.hasUI) { + if (sub === "digest") { + const digest = generateDigest(bg); + ctx.ui.notify(formatDigestText(bg, digest), "info"); + } else { + const output = getOutput(bg, { stream: "both", tail: 50 }); + ctx.ui.notify(output || "(no output)", "info"); + } + return; + } + + await ctx.ui.custom( + (tui, theme, _kb, done) => { + const overlay = new BgManagerOverlay(tui, theme, () => { + done(); + refreshWidget(); + }); + const procs = Array.from(processes.values()); + const idx = procs.findIndex(p => p.id === id); + if (idx >= 0) overlay.selectAndView(idx); + return overlay; + }, + { + overlay: true, + overlayOptions: { + width: "60%", + minWidth: 50, + maxHeight: "70%", + anchor: "center", + }, + }, + ); + return; + } + + if (sub === "kill") { + const id = parts[1]; + if (!id) { + ctx.ui.notify("Usage: /bg kill ", "error"); + return; + } + const bg = processes.get(id); + if (!bg) { + ctx.ui.notify(`No process with id '${id}'`, "error"); + return; + } + killProcess(id, "SIGTERM"); + await new Promise(r => setTimeout(r, 300)); + if (bg.alive) { + killProcess(id, "SIGKILL"); + await new Promise(r => setTimeout(r, 200)); + } + if (!bg.alive) processes.delete(id); + ctx.ui.notify(`Killed process ${id} (${bg.label})`, "info"); + return; + } + + if (sub === "killall") { + const count = processes.size; + cleanupAll(); + ctx.ui.notify(`Killed ${count} background process(es)`, "info"); + return; + } + + if (sub === "groups") { + const groups = new Set(); + for (const p of processes.values()) { + if (p.group) groups.add(p.group); + } + if (groups.size === 0) { + ctx.ui.notify("No process groups defined.", "info"); + return; + } + const lines = Array.from(groups).map(g => { + const gs = getGroupStatus(g); + const icon = gs.healthy ? "✓" : "✗"; + const procs = gs.processes.map(p => `${p.id}(${p.status})`).join(", "); + return `${icon} ${g}: ${procs}`; + }); + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + ctx.ui.notify("Usage: /bg [list|output|digest|kill|killall|groups] [id]", "info"); + }, + }); + + // ── Live Footer ────────────────────────────────────────────────────── + + /** Whether we currently own the footer via setFooter */ + let footerActive = false; + + function buildBgStatusText(th: Theme): string { + const alive = Array.from(processes.values()).filter(p => p.alive); + if (alive.length === 0) return ""; + + const sep = th.fg("dim", " · "); + const items: string[] = []; + for (const p of alive) { + const statusIcon = p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●"); + const name = p.label.length > 14 ? p.label.slice(0, 12) + "…" : p.label; + const portInfo = p.ports.length > 0 ? th.fg("dim", `:${p.ports[0]}`) : ""; + const errBadge = p.recentErrors.length > 0 + ? th.fg("error", ` err:${p.recentErrors.length}`) + : ""; + items.push(`${statusIcon} ${th.fg("muted", name)}${portInfo}${errBadge}`); + } + return items.join(sep); + } + + function formatTokenCount(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; + } + + /** Reference to tui for triggering re-renders when footer is active */ + let footerTui: { requestRender: () => void } | null = null; + + function refreshWidget() { + if (!latestCtx?.hasUI) return; + const alive = Array.from(processes.values()).filter(p => p.alive); + + if (alive.length === 0) { + if (footerActive) { + latestCtx.ui.setFooter(undefined); + footerActive = false; + footerTui = null; + } + return; + } + + if (footerActive) { + // Footer already installed — just trigger a re-render + footerTui?.requestRender(); + return; + } + + // Install custom footer that puts bg process info right-aligned on line 1 + footerActive = true; + latestCtx.ui.setFooter((tui, th, footerData) => { + footerTui = tui; + const branchUnsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + render(width: number): string[] { + // ── Line 1: pwd (branch) [session] ... bg status ── + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) { + pwd = `~${pwd.slice(home.length)}`; + } + const branch = footerData.getGitBranch(); + if (branch) pwd = `${pwd} (${branch})`; + + const sessionName = latestCtx?.sessionManager?.getSessionName?.(); + if (sessionName) pwd = `${pwd} • ${sessionName}`; + + const bgStatus = buildBgStatusText(th); + const leftPwd = th.fg("dim", pwd); + const leftWidth = visibleWidth(leftPwd); + const rightWidth = visibleWidth(bgStatus); + + let pwdLine: string; + const minGap = 2; + if (bgStatus && leftWidth + minGap + rightWidth <= width) { + const pad = " ".repeat(width - leftWidth - rightWidth); + pwdLine = leftPwd + pad + bgStatus; + } else if (bgStatus) { + // Truncate pwd to make room for bg status + const availForPwd = width - rightWidth - minGap; + if (availForPwd > 10) { + const truncPwd = truncateToWidth(leftPwd, availForPwd, th.fg("dim", "…")); + const truncWidth = visibleWidth(truncPwd); + const pad = " ".repeat(Math.max(0, width - truncWidth - rightWidth)); + pwdLine = truncPwd + pad + bgStatus; + } else { + pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…")); + } + } else { + pwdLine = truncateToWidth(leftPwd, width, th.fg("dim", "…")); + } + + // ── Line 2: token stats (left) ... model (right) ── + const ctx = latestCtx; + const sm = ctx?.sessionManager; + let totalInput = 0, totalOutput = 0; + let totalCacheRead = 0, totalCacheWrite = 0, totalCost = 0; + if (sm) { + for (const entry of sm.getEntries()) { + if (entry.type === "message" && (entry as any).message?.role === "assistant") { + const u = (entry as any).message.usage; + if (u) { + totalInput += u.input || 0; + totalOutput += u.output || 0; + totalCacheRead += u.cacheRead || 0; + totalCacheWrite += u.cacheWrite || 0; + totalCost += u.cost?.total || 0; + } + } + } + } + + const contextUsage = ctx?.getContextUsage?.(); + const contextWindow = contextUsage?.contextWindow ?? ctx?.model?.contextWindow ?? 0; + const contextPercentValue = contextUsage?.percent ?? 0; + const contextPercent = contextUsage?.percent !== null ? (contextPercentValue).toFixed(1) : "?"; + + const statsParts: string[] = []; + if (totalInput) statsParts.push(`↑${formatTokenCount(totalInput)}`); + if (totalOutput) statsParts.push(`↓${formatTokenCount(totalOutput)}`); + if (totalCacheRead) statsParts.push(`R${formatTokenCount(totalCacheRead)}`); + if (totalCacheWrite) statsParts.push(`W${formatTokenCount(totalCacheWrite)}`); + if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`); + + const contextDisplay = contextPercent === "?" + ? `?/${formatTokenCount(contextWindow)}` + : `${contextPercent}%/${formatTokenCount(contextWindow)}`; + let contextStr: string; + if (contextPercentValue > 90) { + contextStr = th.fg("error", contextDisplay); + } else if (contextPercentValue > 70) { + contextStr = th.fg("warning", contextDisplay); + } else { + contextStr = contextDisplay; + } + statsParts.push(contextStr); + + let statsLeft = statsParts.join(" "); + let statsLeftWidth = visibleWidth(statsLeft); + if (statsLeftWidth > width) { + statsLeft = truncateToWidth(statsLeft, width, "..."); + statsLeftWidth = visibleWidth(statsLeft); + } + + const modelName = ctx?.model?.id || "no-model"; + let rightSide = modelName; + if (ctx?.model?.reasoning) { + const thinkingLevel = (ctx as any).getThinkingLevel?.() || "off"; + rightSide = thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`; + } + if (footerData.getAvailableProviderCount() > 1 && ctx?.model) { + const withProvider = `(${ctx.model.provider}) ${rightSide}`; + if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) { + rightSide = withProvider; + } + } + + const rightSideWidth = visibleWidth(rightSide); + let statsLine: string; + if (statsLeftWidth + 2 + rightSideWidth <= width) { + const pad = " ".repeat(width - statsLeftWidth - rightSideWidth); + statsLine = statsLeft + pad + rightSide; + } else { + const avail = width - statsLeftWidth - 2; + if (avail > 0) { + const truncRight = truncateToWidth(rightSide, avail, ""); + const truncRightWidth = visibleWidth(truncRight); + const pad = " ".repeat(Math.max(0, width - statsLeftWidth - truncRightWidth)); + statsLine = statsLeft + pad + truncRight; + } else { + statsLine = statsLeft; + } + } + + const dimStatsLeft = th.fg("dim", statsLeft); + const remainder = statsLine.slice(statsLeft.length); + const dimRemainder = th.fg("dim", remainder); + + const lines = [pwdLine, dimStatsLeft + dimRemainder]; + + // ── Line 3 (optional): other extension statuses ── + const extensionStatuses = footerData.getExtensionStatuses(); + // Filter out our own bg-shell status since it's already on line 1 + const otherStatuses = Array.from(extensionStatuses.entries()) + .filter(([key]) => key !== "bg-shell") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim()); + if (otherStatuses.length > 0) { + lines.push(truncateToWidth(otherStatuses.join(" "), width, th.fg("dim", "..."))); + } + + return lines; + }, + invalidate() {}, + dispose() { + branchUnsub(); + footerTui = null; + }, + }; + }); + } + + // Periodic maintenance + const maintenanceInterval = setInterval(() => { + pruneDeadProcesses(); + refreshWidget(); + // Persist manifest periodically + if (latestCtx) { + persistManifest(latestCtx.cwd); + } + }, 2000); + + // Refresh widget after agent actions and session events + for (const event of [ + "turn_end", + "agent_end", + "session_start", + "session_switch", + ] as const) { + pi.on(event, async (_event: unknown, ctx: ExtensionContext) => { + latestCtx = ctx; + refreshWidget(); + }); + } + + pi.on("tool_execution_end", async (_event, ctx) => { + latestCtx = ctx; + refreshWidget(); + }); + + // ── Ctrl+Alt+B shortcut ────────────────────────────────────────────── + + pi.registerShortcut(Key.ctrlAlt("b"), { + description: "Open background process manager", + handler: async (ctx) => { + latestCtx = ctx; + await ctx.ui.custom( + (tui, theme, _kb, done) => { + return new BgManagerOverlay(tui, theme, () => { + done(); + refreshWidget(); + }); + }, + { + overlay: true, + overlayOptions: { + width: "60%", + minWidth: 50, + maxHeight: "70%", + anchor: "center", + }, + }, + ); + }, + }); + + // Clean up on shutdown + pi.on("session_shutdown", async () => { + clearInterval(maintenanceInterval); + if (latestCtx) persistManifest(latestCtx.cwd); + cleanupAll(); + }); +} + +// ── TUI: Process Manager Overlay ─────────────────────────────────────────── + +class BgManagerOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: () => void; + private selected = 0; + private mode: "list" | "output" | "events" = "list"; + private viewingProcess: BgProcess | null = null; + private scrollOffset = 0; + private cachedWidth?: number; + private cachedLines?: string[]; + private refreshTimer: ReturnType; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + onClose: () => void, + ) { + this.tui = tui; + this.theme = theme; + this.onClose = onClose; + this.refreshTimer = setInterval(() => { + this.invalidate(); + this.tui.requestRender(); + }, 1000); + } + + private getProcessList(): BgProcess[] { + return Array.from(processes.values()); + } + + selectAndView(index: number): void { + const procs = this.getProcessList(); + if (index >= 0 && index < procs.length) { + this.selected = index; + this.viewingProcess = procs[index]; + this.mode = "output"; + this.scrollOffset = Math.max(0, procs[index].output.length - 20); + } + } + + handleInput(data: string): void { + if (this.mode === "output") { + this.handleOutputInput(data); + return; + } + if (this.mode === "events") { + this.handleEventsInput(data); + return; + } + this.handleListInput(data); + } + + private handleListInput(data: string): void { + const procs = this.getProcessList(); + + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("b"))) { + clearInterval(this.refreshTimer); + this.onClose(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + if (this.selected > 0) { + this.selected--; + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.selected < procs.length - 1) { + this.selected++; + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + if (matchesKey(data, Key.enter)) { + const proc = procs[this.selected]; + if (proc) { + this.viewingProcess = proc; + this.mode = "output"; + this.scrollOffset = Math.max(0, proc.output.length - 20); + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + // e = view events + if (data === "e") { + const proc = procs[this.selected]; + if (proc) { + this.viewingProcess = proc; + this.mode = "events"; + this.scrollOffset = Math.max(0, proc.events.length - 15); + this.invalidate(); + this.tui.requestRender(); + } + return; + } + + // r = restart + if (data === "r") { + const proc = procs[this.selected]; + if (proc) { + restartProcess(proc.id).then(() => { + this.invalidate(); + this.tui.requestRender(); + }); + } + return; + } + + // x or d = kill selected + if (data === "x" || data === "d") { + const proc = procs[this.selected]; + if (proc && proc.alive) { + killProcess(proc.id, "SIGTERM"); + setTimeout(() => { + if (proc.alive) killProcess(proc.id, "SIGKILL"); + this.invalidate(); + this.tui.requestRender(); + }, 300); + } + return; + } + + // X or D = kill all + if (data === "X" || data === "D") { + cleanupAll(); + this.selected = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + private handleOutputInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { + this.mode = "list"; + this.viewingProcess = null; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Tab to switch to events view + if (matchesKey(data, Key.tab)) { + this.mode = "events"; + if (this.viewingProcess) { + this.scrollOffset = Math.max(0, this.viewingProcess.events.length - 15); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.viewingProcess) { + const total = this.viewingProcess.output.length; + this.scrollOffset = Math.min(this.scrollOffset + 5, Math.max(0, total - 20)); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 5); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "G") { + if (this.viewingProcess) { + const total = this.viewingProcess.output.length; + this.scrollOffset = Math.max(0, total - 20); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "g") { + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + private handleEventsInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { + this.mode = "list"; + this.viewingProcess = null; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Tab to switch back to output view + if (matchesKey(data, Key.tab)) { + this.mode = "output"; + if (this.viewingProcess) { + this.scrollOffset = Math.max(0, this.viewingProcess.output.length - 20); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + if (this.viewingProcess) { + this.scrollOffset = Math.min(this.scrollOffset + 3, Math.max(0, this.viewingProcess.events.length - 10)); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 3); + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + let lines: string[]; + if (this.mode === "events") { + lines = this.renderEvents(width); + } else if (this.mode === "output") { + lines = this.renderOutput(width); + } else { + lines = this.renderList(width); + } + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + private box(inner: string[], width: number): string[] { + const th = this.theme; + const bdr = (s: string) => th.fg("borderMuted", s); + const iw = width - 4; + const lines: string[] = []; + + lines.push(bdr("╭" + "─".repeat(width - 2) + "╮")); + for (const line of inner) { + const truncated = truncateToWidth(line, iw); + const pad = Math.max(0, iw - visibleWidth(truncated)); + lines.push(bdr("│") + " " + truncated + " ".repeat(pad) + " " + bdr("│")); + } + lines.push(bdr("╰" + "─".repeat(width - 2) + "╯")); + return lines; + } + + private renderList(width: number): string[] { + const th = this.theme; + const procs = this.getProcessList(); + const inner: string[] = []; + + if (procs.length === 0) { + inner.push(th.fg("dim", "No background processes.")); + inner.push(""); + inner.push(th.fg("dim", "esc close")); + return this.box(inner, width); + } + + inner.push(th.fg("dim", "Background Processes")); + inner.push(""); + + for (let i = 0; i < procs.length; i++) { + const p = procs[i]; + const sel = i === this.selected; + const pointer = sel ? th.fg("accent", "▸ ") : " "; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const name = sel ? th.fg("text", p.label) : th.fg("muted", p.label); + const typeTag = th.fg("dim", `[${p.processType}]`); + const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; + const errBadge = p.recentErrors.length > 0 ? th.fg("error", ` ⚠${p.recentErrors.length}`) : ""; + const groupTag = p.group ? th.fg("dim", ` {${p.group}}`) : ""; + const restartBadge = p.restartCount > 0 ? th.fg("warning", ` ↻${p.restartCount}`) : ""; + + const status = p.alive ? "" : " " + th.fg("dim", `exit ${p.exitCode}`); + + inner.push(`${pointer}${statusIcon} ${name} ${typeTag} ${uptime}${portInfo}${errBadge}${groupTag}${restartBadge}${status}`); + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ select · enter output · e events · r restart · x kill · esc close")); + + return this.box(inner, width); + } + + private renderOutput(width: number): string[] { + const th = this.theme; + const p = this.viewingProcess; + if (!p) return [""]; + const inner: string[] = []; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + const name = th.fg("muted", p.label); + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const typeTag = th.fg("dim", `[${p.processType}]`); + const portInfo = p.ports.length > 0 ? th.fg("dim", ` :${p.ports.join(",")}`) : ""; + const tabIndicator = th.fg("accent", "[Output]") + " " + th.fg("dim", "Events"); + + inner.push(`${statusIcon} ${name} ${typeTag} ${uptime}${portInfo} ${tabIndicator}`); + inner.push(""); + + // Unified buffer is already chronologically interleaved + const allOutput = p.output; + + const maxVisible = 18; + const visible = allOutput.slice(this.scrollOffset, this.scrollOffset + maxVisible); + + if (allOutput.length === 0) { + inner.push(th.fg("dim", "(no output)")); + } else { + for (const entry of visible) { + const isError = ERROR_PATTERNS.some(pat => pat.test(entry.line)); + const isWarning = !isError && WARNING_PATTERNS.some(pat => pat.test(entry.line)); + const prefix = entry.stream === "stderr" ? th.fg("error", "⚠ ") : ""; + const color = isError ? "error" : isWarning ? "warning" : "dim"; + inner.push(prefix + th.fg(color, entry.line)); + } + + if (allOutput.length > maxVisible) { + inner.push(""); + const pos = `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, allOutput.length)} of ${allOutput.length}`; + inner.push(th.fg("dim", pos)); + } + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ scroll · g/G top/end · tab events · q back")); + + return this.box(inner, width); + } + + private renderEvents(width: number): string[] { + const th = this.theme; + const p = this.viewingProcess; + if (!p) return [""]; + const inner: string[] = []; + + const statusIcon = p.alive + ? (p.status === "ready" ? th.fg("success", "●") + : p.status === "error" ? th.fg("error", "●") + : th.fg("warning", "●")) + : th.fg("dim", "○"); + const name = th.fg("muted", p.label); + const uptime = th.fg("dim", formatUptime(Date.now() - p.startedAt)); + const tabIndicator = th.fg("dim", "Output") + " " + th.fg("accent", "[Events]"); + + inner.push(`${statusIcon} ${name} ${uptime} ${tabIndicator}`); + inner.push(""); + + if (p.events.length === 0) { + inner.push(th.fg("dim", "(no events)")); + } else { + const maxVisible = 15; + const visible = p.events.slice(this.scrollOffset, this.scrollOffset + maxVisible); + + for (const ev of visible) { + const time = th.fg("dim", formatTimeAgo(ev.timestamp)); + const typeColor = ev.type === "crashed" || ev.type === "error_detected" ? "error" + : ev.type === "ready" || ev.type === "recovered" ? "success" + : ev.type === "port_open" ? "accent" + : "dim"; + const typeLabel = th.fg(typeColor, ev.type); + inner.push(`${time} ${typeLabel}`); + inner.push(` ${th.fg("dim", ev.detail.slice(0, 80))}`); + } + + if (p.events.length > maxVisible) { + inner.push(""); + inner.push(th.fg("dim", `${this.scrollOffset + 1}–${Math.min(this.scrollOffset + maxVisible, p.events.length)} of ${p.events.length} events`)); + } + } + + inner.push(""); + inner.push(th.fg("dim", "↑↓ scroll · tab output · q back")); + + return this.box(inner, width); + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} diff --git a/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md b/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md new file mode 100644 index 000000000..d8c1b2aef --- /dev/null +++ b/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md @@ -0,0 +1,1277 @@ +# Browser Tools V2 Proposal + +## Purpose + +This document proposes a comprehensive evolution of `agent/extensions/browser-tools/` from a strong set of browser-control primitives into a world-class AI-native browser device for: + +- autonomous verification +- end-to-end testing +- GSD slice validation +- debugging and observability +- general internet task execution +- low-token, high-reliability browser interaction + +The goal is not just to let the agent click around in a browser. The goal is to give the agent **hands, eyes, memory, verification, and local judgment** in a way that is: + +- context-efficient +- fast +- deterministic where possible +- observable when things fail +- composable for larger workflows +- optimized for LLM use, not human scripting ergonomics + +--- + +## Executive Summary + +The current browser tools already make several unusually good architectural choices: + +- accessibility-first inspection instead of screenshot-first browsing +- deterministic versioned element refs +- compact post-action summaries instead of full DOM spam +- buffered observability surfaces for console, network, and dialogs +- lightweight success verification after actions +- adaptive settling instead of blindly waiting for `networkidle` + +Those choices align well with March 2026 best practices in AI browser automation. + +However, the current system still operates mostly as a **toolbox of action primitives**. To become a truly elite AI-native browser device, it should evolve in six major directions: + +1. **Assertions over prose** — explicit PASS/FAIL verification tools +2. **Composite actions over chatty primitive loops** — batch, form fill, goal-oriented flows +3. **Diffs over full resnapshots** — tell the agent what changed, not just what exists now +4. **Stateful browser modeling** — tabs, frames, forms, dialogs, refs, action history +5. **Failure artifacts and observability** — traces, bundles, structured debug evidence +6. **Intent-aware semantic helpers** — find the best next element/action for a goal + +If implemented well, these changes would make browser-tools materially better for both: + +- **GSD automatic verification and UAT generation** +- **general-purpose agentic browser use on arbitrary websites and apps** + +--- + +## Current State: What Browser Tools Already Does Well + +The existing extension in `agent/extensions/browser-tools/index.ts` already gets several important things right. + +### 1. Accessibility-first state representation + +The system already prefers: + +- `browser_get_accessibility_tree` +- `browser_find` +- `browser_snapshot_refs` + +This is the correct strategic direction. Accessibility snapshots are usually far more token-efficient and reliable than: + +- full HTML dumps +- screenshot-only operation +- coordinate-based automation + +### 2. Deterministic element references + +The versioned ref system (`@vN:e1`) is one of the strongest parts of the current design. + +It provides: + +- compact handles for later actions +- stale-ref detection +- lower repeated selector verbosity +- less guesswork for the model + +This aligns closely with current agent-browser and Playwright MCP design patterns. + +### 3. Compact post-action summaries + +The `postActionSummary()` helper is a strong design decision. + +It gives the agent: + +- title +- URL +- high-level element counts +- important headings +- focus state +- dialog hints + +without flooding context. + +### 4. Pull-based observability + +Buffered logs for: + +- console +- network +- dialogs + +are exactly the right pattern. + +This prevents every tool call from becoming noisy while still preserving rich debugging when needed. + +### 5. Built-in self-verification on interactions + +The current tools already attempt to verify success through signals like: + +- URL changes +- hash changes +- target ARIA state changes +- value changes +- focus changes +- dialog count changes + +This is much better than blind action execution. + +### 6. Adaptive settling + +The mutation counter plus pending-critical-request model is clever and practical. + +It is better than: + +- fixed sleeps everywhere +- hard dependence on `networkidle` +- no settle logic at all + +### 7. Sensible visual fallback strategy + +The extension already uses screenshots as: + +- navigation-time context +- explicit inspection output +- failure debugging evidence + +That is good. Screenshots should support semantics, not replace them. + +--- + +## Core Diagnosis + +### What the current system is + +Right now, browser-tools is primarily a **semantic browser control toolkit**. + +That is already useful and better than many browser agent stacks. + +### What it should become + +It should become an **AI-native browser operating layer** that gives the model: + +- reliable control +- compact semantic state +- explicit verification +- efficient action composition +- better local reasoning support +- durable debugging artifacts + +### The central gap + +The biggest gap is that the extension currently optimizes for **individual actions** more than **successful browser tasks**. + +That difference matters. + +An elite browser device for AI should optimize for: + +- “did the task succeed?” +- “what changed?” +- “what should I do next?” +- “can I verify this automatically?” +- “if it failed, what evidence do I have?” + +not just: + +- “did the click happen?” +- “here is the current page summary” + +--- + +## Design Principles for V2 + +The proposed system should follow these principles. + +### 1. Semantics first, vision second + +Preferred order of understanding: + +1. structured semantic state +2. scoped accessibility/tree snapshots +3. ranked semantic refs +4. DOM or JS inspection when needed +5. screenshots only when semantics are insufficient or visual truth matters + +### 2. Assertions are first-class + +Every serious verification system needs explicit assertions. + +Tool outputs should prefer structured verification objects over prose. + +### 3. Minimize round trips + +The fastest tool call is the one the model does not need to make. + +Obvious action sequences should be batchable. + +### 4. Model the browser as state, not just a stream of actions + +The extension should internally track: + +- pages/tabs +- frames +- dialogs +- form structures +- refs +- last known page summaries +- diffs across actions +- recent action outcomes + +### 5. Tell the agent what changed + +State deltas are often more useful than fresh full state. + +### 6. Heavy artifacts belong on disk, not in context + +Trace files, HAR data, visual diffs, and debug bundles should generally be persisted and summarized, not inlined. + +### 7. Optimize for GSD verification + +The browser device should be excellent at producing: + +- deterministic pass/fail checks +- concise verification summaries +- debug artifacts on failure +- machine-usable evidence for slice/task summaries and UAT + +--- + +## Proposed Changes + +# 1. Add a First-Class Assertion System + +## Proposal + +Add a `browser_assert` tool and a small assertion language built around common browser verification needs. + +## Why it matters + +This is the single most important missing capability for GSD and autonomous QA. + +Today the agent must infer correctness from prose and heuristics. That is weaker than explicit pass/fail evaluation. + +## What it enables + +- deterministic verification +- clean GSD artifact generation +- structured failure reporting +- simpler agent reasoning +- less repeated browser inspection + +## Suggested assertion kinds + +### Page state assertions +- `url_contains` +- `url_equals` +- `title_contains` +- `page_ready` +- `page_has_dialog` +- `page_has_alert` + +### Element assertions +- `selector_visible` +- `selector_hidden` +- `ref_visible` +- `ref_enabled` +- `text_visible` +- `text_not_visible` +- `focused_matches` +- `value_equals` +- `value_contains` +- `checked_equals` +- `count_equals` +- `count_at_least` + +### Accessibility assertions +- `aria_snapshot_contains` +- `aria_snapshot_matches` +- `role_name_exists` +- `dialog_open` +- `alert_visible` + +### Observability assertions +- `no_console_errors` +- `network_request_seen` +- `response_status_seen` +- `no_failed_requests` +- `dialog_seen` + +### Visual assertions +- `screenshot_changed` +- `element_visually_changed` +- `layout_breakpoint_ok` + +## Suggested output shape + +```json +{ + "verified": true, + "checks": [ + { + "name": "url_contains", + "passed": true, + "actual": "http://localhost:3000/dashboard", + "expected": "/dashboard" + }, + { + "name": "no_console_errors", + "passed": true, + "actual": 0 + } + ], + "summary": "PASS (2/2 checks)", + "agent_hint": "Dashboard loaded without browser-side errors" +} +``` + +## Additional recommendation + +Support both: + +- single assertions +- multi-check assertions in one call + +This keeps verification compact and expressive. + +--- + +# 2. Add `browser_batch` for Composite Action Execution + +## Proposal + +Add a batch or transaction-style tool that executes multiple browser steps in a single tool call. + +## Why it matters + +This is one of the highest-ROI speed and token-efficiency improvements. + +Many browser tasks currently require a chatty loop: + +- find +- click +- type +- wait +- inspect +- verify + +A batch tool collapses obvious sequential actions into one round trip. + +## What it enables + +- fewer tool invocations +- lower latency +- lower schema overhead +- less repetitive page-summary generation +- more deterministic execution of known action sequences + +## Example + +```json +{ + "steps": [ + { "action": "click_ref", "ref": "@v3:e2" }, + { "action": "fill_ref", "ref": "@v3:e5", "text": "lex@example.com" }, + { "action": "fill_ref", "ref": "@v3:e6", "text": "password123" }, + { "action": "click_ref", "ref": "@v3:e7" }, + { "action": "wait_for", "condition": "url_contains", "value": "/dashboard" }, + { "action": "assert", "kind": "text_visible", "text": "Dashboard" } + ], + "stopOnFailure": true, + "finalSummaryOnly": true +} +``` + +## Recommended options + +- `stopOnFailure` +- `captureIntermediateState` +- `includeIntermediateDiagnostics` +- `finalSummaryOnly` +- `returnStepResults` + +## Design note + +This should not replace primitive tools. It should sit above them. + +--- + +# 3. Add `browser_diff` to Report What Changed + +## Proposal + +Add a diff tool that compares two browser states or the pre/post state around an action. + +## Why it matters + +The model frequently needs to answer: + +- did the click do anything? +- what changed after submit? +- what new UI appeared? +- what should I inspect next? + +A change summary is usually more useful than a fresh full snapshot. + +## What it enables + +- faster reasoning after actions +- better success detection +- lower token usage +- easier failure diagnosis +- improved “next action” selection + +## Suggested diff dimensions + +- URL change +- title change +- focus change +- dialog open/close +- heading additions/removals +- new alerts/errors/toasts +- interactive element count changes +- text changes in scoped region +- ARIA subtree changes +- validation error changes +- scroll position changes +- form state changes + +## Example output + +```json +{ + "changed": true, + "changes": [ + { "type": "url", "before": "/login", "after": "/dashboard" }, + { "type": "dialog_closed", "value": "Sign in" }, + { "type": "new_heading", "value": "Dashboard" } + ], + "summary": "Navigation completed and login modal closed", + "agent_hint": "Authentication likely succeeded" +} +``` + +## Implementation note + +A lightweight internal state snapshot should be stored after major actions so diffs are cheap. + +--- + +# 4. Add Form Intelligence + +## Proposal + +Add form-specific analysis and fill tools. + +### New tools +- `browser_analyze_form` +- `browser_fill_form` + +## Why it matters + +A large percentage of browser tasks are fundamentally form tasks: + +- sign in +- sign up +- checkout +- onboarding +- search +- settings +- admin actions +- content publishing + +Forms are one of the highest-leverage abstractions in browser automation. + +## What it enables + +- fewer calls for common flows +- stronger semantic mapping between labels and inputs +- automatic handling of required fields and validation messages +- better submit targeting +- more robust GSD verification of user flows + +## `browser_analyze_form` should return + +- form purpose inference +- fields and labels +- field types +- required status +- current values +- current validation errors +- submit controls +- grouped sections +- likely primary action + +## `browser_fill_form` should support + +```json +{ + "selector": "form", + "values": { + "email": "lex@example.com", + "password": "hunter2" + }, + "submit": true, + "strict": false +} +``` + +## Important design behavior + +It should map values by: + +- label text +- accessible name +- field name +- placeholder when needed +- form-local semantic inference + +## Recommended output + +- matched fields +- unmatched requested values +- fields skipped +- validation state after fill +- submit result summary + +--- + +# 5. Add Intent-Ranked Element Retrieval + +## Proposal + +Add a smarter semantic finder, such as `browser_find_best`. + +## Why it matters + +The current `browser_find` is useful but still fairly literal. Agents often need a ranked answer to questions like: + +- what is the primary CTA? +- which button submits this form? +- which textbox is the email field? +- what element most likely advances login? +- which visible error is most relevant right now? + +## What it enables + +- better action selection +- fewer failed clicks +- less token spent interpreting noisy candidate lists +- more autonomous local decisions + +## Example + +```json +{ + "intent": "submit login form", + "candidates": [ + { + "ref": "@v5:e7", + "score": 0.93, + "reason": "button in same form as email and password fields named Sign in" + }, + { + "ref": "@v5:e9", + "score": 0.41, + "reason": "secondary link outside form" + } + ] +} +``` + +## Suggested intents + +- submit form +- primary CTA +- close dialog +- search field +- next step +- destructive action +- auth action +- error surface +- back navigation +- menu trigger + +## Design recommendation + +This should be deterministic heuristic ranking first, not a hidden LLM. + +--- + +# 6. Upgrade the Ref System + +## Proposal + +Keep versioned refs, but evolve them into a richer semantic reference system. + +## Why it matters + +Refs are the backbone of efficient browser interaction. The current system is good; the next step is to make refs more resilient, more semantic, and more useful across changing DOMs. + +## What it enables + +- lower selector dependence +- better recovery from DOM churn +- more compact instructions +- clearer reasoning for the agent + +## Proposed upgrades + +### A. Snapshot modes +Allow specialized snapshot modes: + +- `interactive` +- `form` +- `dialog` +- `navigation` +- `errors` +- `headings` +- `visible_only` + +This reduces token waste and improves relevance. + +### B. Better internal fingerprints +Track more stable descriptors: + +- role +- accessible name +- type +- href +- form ownership +- ancestry signature +- relative region +- label association +- nearby headings + +This helps ref remapping across light DOM changes. + +### C. Semantic aliases +Potentially expose alias-like labels such as: + +- primary submit +- close dialog +- current tab +- email field +- password field + +Even if these remain derived rather than canonical, they can improve action clarity. + +### D. Scoped ref groups +Allow refs generated per region: + +- within dialog +- within main +- within form +- within sidebar + +This helps reduce ambiguity. + +--- + +# 7. Add Browser Session Modeling: Tabs, Pages, Frames + +## Proposal + +Promote the internal browser model from “single active page” to a real page registry. + +### New tools +- `browser_list_pages` +- `browser_switch_page` +- `browser_close_page` +- `browser_list_frames` +- `browser_select_frame` + +## Why it matters + +Real browser flows often involve: + +- popups +- auth redirects +- payment tabs +- docs tabs +- embedded auth iframes +- admin consoles with frames + +A single global `page` pointer does not scale well. + +## What it enables + +- more reliable multi-tab flows +- less hidden state confusion +- better popup handling +- frame-aware automation +- clearer debugging when navigation opens a new surface + +## Recommended session model + +Track: + +- page id +- opener relationship +- title +- URL +- last active time +- frame inventory +- whether page was auto-opened or explicitly targeted + +## Design recommendation + +Auto-switching to a newly opened page is still useful, but should be visible and inspectable. + +--- + +# 8. Add Tracing and Failure Artifacts + +## Proposal + +Add explicit debug artifact tools. + +### New tools +- `browser_trace_start` +- `browser_trace_stop` +- `browser_export_har` +- `browser_debug_bundle` +- `browser_timeline` +- `browser_session_summary` + +## Why it matters + +For GSD and for hard UI debugging, you need failure evidence that survives the current context window. + +## What it enables + +- durable debugging artifacts +- post-failure inspection without replaying everything +- easier handoff across sessions or agents +- structured evidence for summaries and UAT docs + +## `browser_debug_bundle` should ideally include + +- current URL/title +- viewport +- recent actions +- compact recent warnings +- recent console errors +- recent failed/important requests +- active dialogs +- screenshot path or inline thumbnail +- scoped AX snapshot near likely failure area +- trace path if enabled +- concise failure hypothesis + +## Artifact policy + +Heavy artifacts should be written to disk and summarized in tool output. + +Example return: + +```json +{ + "bundlePath": ".artifacts/browser/failure-2026-03-09T15-22-10Z/", + "files": ["trace.zip", "screenshot.jpg", "summary.json", "ax.md"], + "summary": "Submit button click did not change URL or form state; network returned 422" +} +``` + +--- + +# 9. Add Goal-Oriented Composite Tools + +## Proposal + +Add tools that operate one level above raw browser actions. + +### Candidate tools +- `browser_act` +- `browser_run_task` +- `browser_recommend_next` +- `browser_verify_flow` + +## Why it matters + +The model should not have to fully re-solve every local browser decision through multiple turns if the browser device can cheaply reason about obvious next steps. + +## What it enables + +- reduced local decision overhead +- more agent autonomy +- bounded browser-side loops for repetitive UI micro-tasks +- cleaner higher-level orchestration + +## Suggested roles + +### `browser_recommend_next` +Given a goal and current page state, return the best next 3 actions with confidence and reasons. + +### `browser_act` +Perform one higher-level semantic action like: + +- open login dialog +- submit current form +- close active modal +- click primary CTA +- expand navigation menu + +### `browser_verify_flow` +Run a bounded set of assertions for a named flow such as: + +- logged in +- signed out +- item created +- toast appeared +- navigation completed + +### `browser_run_task` +Frontier tool: perform a bounded internal action loop toward a clear goal. + +## Safety recommendations + +These tools must be bounded by: + +- max step count +- allowed action categories +- destructive action restrictions +- explicit halt conditions + +--- + +# 10. Add Better Waits and Reactive Predicates + +## Proposal + +Replace or augment `browser_wait_for` with a richer `browser_wait_until`. + +## Why it matters + +Generic waiting is weaker than intent-aware waiting. The best wait is waiting for the expected outcome. + +## What it enables + +- higher reliability +- fewer arbitrary delays +- better async app support +- less flakiness in SPA and real-time UIs + +## Suggested predicates + +- text appears/disappears +- ref state changes +- element count changes +- request matching pattern completes +- response with status seen +- toast appears +- dialog opens/closes +- loading spinner disappears +- route transition completes +- region stops changing +- focus reaches expected element + +## Design note + +This should integrate with the same state/diff infrastructure proposed above. + +--- + +# 11. Make Screenshots More Selective and More Useful + +## Proposal + +Keep screenshots, but use them more surgically. + +### New tools or behaviors +- `browser_screenshot_diff` +- `browser_capture_region` +- `browser_inspect_visual` + +## Why it matters + +Screenshots are valuable when: + +- the UI is canvas-based +- layout quality matters +- icon-only controls are ambiguous +- a visual regression is suspected +- CSS behavior matters +- semantic state is insufficient + +But screenshots are often too expensive and too noisy to be the default state transport. + +## What it enables + +- better visual debugging when actually needed +- less token waste than full-page screenshots +- pairing visual evidence with semantic evidence + +## Recommended direction + +- make screenshots scoped and purposeful +- prefer element/region crops over full-page captures +- pair screenshot outputs with semantic context and diffs +- support perceptual diff summaries instead of raw image-only comparisons + +--- + +# 12. Add Structured Network and Console Assertions + +## Proposal + +Evolve buffered observability from passive retrieval into active verification and querying. + +## Why it matters + +Modern web apps often fail in ways only visible through: + +- fetch/XHR failures +- console errors +- CSP/CORS issues +- React hydration errors +- auth-related 401/403s + +These should be easy for the agent to test explicitly. + +## What it enables + +- stronger root-cause detection +- better end-to-end verification +- fewer false positives where UI looked okay but requests failed + +## Suggested additions + +- filter by request URL pattern +- filter by method/resource type/status range +- query logs since action id or timestamp +- assert request happened +- assert response status seen +- assert no console errors of severity >= error +- assert no failed XHR/fetch during flow + +--- + +# 13. Add an Action Timeline and Action IDs + +## Proposal + +Assign every browser action an internal action id and keep a lightweight action timeline. + +## Why it matters + +This makes the system far more debuggable and composable. + +## What it enables + +- diff since action N +- logs since action N +- request correlation +- failure bundle generation +- concise flow summaries +- better GSD verification records + +## Suggested stored fields per action + +- action id +- tool name +- params summary +- page id +- timestamp start/end +- verification outcome +- detected changes +- relevant warnings + +--- + +# 14. Tighten Tool Descriptions and Prompt Guidance + +## Proposal + +Refine tool descriptions so the model understands exactly what each tool returns and when to use it. + +## Why it matters + +A surprising amount of agent inefficiency comes from slightly misleading tool expectations. + +## Current issue + +Some tools describe outputs in terms like “returns accessibility snapshot” when they more accurately return a compact page summary. + +## What it enables + +- better tool selection +- fewer redundant follow-up calls +- less confusion about when to use full AX vs compact find vs summaries + +## Recommended prompt guidance hierarchy + +For state inspection, teach the model to prefer: + +1. `browser_find` +2. `browser_snapshot_refs` +3. `browser_assert` +4. `browser_diff` +5. `browser_get_accessibility_tree` +6. `browser_get_page_source` +7. `browser_evaluate` + +This keeps common browsing token-efficient. + +--- + +# 15. Add Browser-Side State Compression and Delta Reporting + +## Proposal + +Internally maintain a compact page model and expose only deltas unless the agent asks for full detail. + +## Why it matters + +This is one of the biggest long-term wins for context efficiency. + +## What it enables + +- state reuse across tool calls +- lower repeated summaries +- cheaper comparison after actions +- better change detection +- smarter internal recommendations + +## Internal state could include + +- last summary +- heading set +- visible alerts +- dialog inventory +- interactive ref list +- form inventory +- last screenshot hash +- last AX signatures for key scopes + +## Output policy + +The default response should prefer: + +- what changed +- what likely matters +- what the agent might want next + +rather than always restating the whole page summary. + +--- + +# 16. Add GSD-Native Verification Outputs + +## Proposal + +Make browser-tools able to emit outputs that directly support GSD slice/task completion. + +## Why it matters + +You explicitly want browser tools to power automatic verification and testing during `@agent/extensions/gsd/` use. + +## What it enables + +- easier automatic generation of `Sxx-UAT.md` content +- deterministic slice verification evidence +- less ad hoc summarization by the agent +- clearer “done/not done” boundaries + +## Suggested additions + +### `browser_verify_flow` +Return: + +- named flow +- steps attempted +- checks passed/failed +- evidence links/paths +- final verdict + +### `browser_export_verification_report` +Write a markdown or JSON artifact summarizing: + +- environment +- URL(s) +- viewport(s) +- actions +- assertions +- outcome +- diagnostics + +This is especially useful for GSD artifacts. + +--- + +## Proposed Roadmap + +## Phase 1 — Highest-ROI Near-Term Upgrades + +These are the best immediate improvements. + +### 1. `browser_assert` +Highest priority. + +### 2. `browser_batch` +Highest priority. + +### 3. `browser_diff` +Highest priority. + +### 4. `browser_analyze_form` +Very high priority. + +### 5. `browser_fill_form` +Very high priority. + +### 6. Tighten tool descriptions and prompt guidance +Low risk, immediate value. + +### 7. Action timeline / action ids +Important enabling infrastructure. + +--- + +## Phase 2 — Strong Maturity Upgrades + +### 8. Multi-page/tab/frame model +### 9. Richer wait predicates +### 10. Structured network/console assertions +### 11. Ref snapshot modes and better ref fingerprints +### 12. Debug bundle and trace export + +--- + +## Phase 3 — Frontier AI-Native Capabilities + +### 13. `browser_find_best` +### 14. `browser_recommend_next` +### 15. `browser_act` +### 16. `browser_verify_flow` +### 17. `browser_run_task` +### 18. hybrid semantic + visual fallback targeting + +These are the ideas that move the extension from excellent tooling into a genuinely mind-blowing browser device for agents. + +--- + +## Detailed Impact Summary + +## Biggest wins for context efficiency + +1. `browser_batch` +2. `browser_diff` +3. snapshot modes for refs +4. assertion outputs instead of prose +5. browser-side state compression/deltas +6. form-level tools replacing many small actions + +## Biggest wins for reliability + +1. `browser_assert` +2. richer waits +3. multi-page/frame awareness +4. structured network/console assertions +5. failure bundles and trace export +6. smarter ref remapping + +## Biggest wins for agent autonomy + +1. `browser_assert` +2. `browser_recommend_next` +3. `browser_find_best` +4. `browser_fill_form` +5. `browser_verify_flow` +6. `browser_run_task` + +## Biggest wins for GSD + +1. explicit verification outputs +2. debug bundles on failure +3. flow verification reports +4. assertion-based PASS/FAIL summaries +5. durable artifact export + +--- + +## What Should Remain True in V2 + +As the extension evolves, it should preserve its best current qualities. + +### Keep these principles +- accessibility-first browsing +- deterministic refs +- compact summaries +- pull-based diagnostics +- verification after action +- screenshots as support, not default state transport +- adaptive settling + +### Avoid these regressions +- screenshot-first browsing as the normal path +- giant raw DOM dumps as default output +- excessive prose instead of structured results +- hidden nondeterminism in action selection +- too many tool calls for common flows +- flaky fixed waits replacing intent-aware checks + +--- + +## Recommended Implementation Order + +If the goal is maximum practical value with strong architectural compounding, implement in this order: + +1. `browser_assert` +2. action timeline / action ids +3. `browser_batch` +4. `browser_diff` +5. `browser_analyze_form` +6. `browser_fill_form` +7. structured network/console assertions +8. multi-page and frame model +9. trace/debug bundle tools +10. ref snapshot modes and richer fingerprints +11. `browser_find_best` +12. `browser_recommend_next` +13. `browser_verify_flow` +14. `browser_run_task` + +This order gives immediate value while laying down the right primitives for more ambitious features. + +--- + +## Final Recommendation + +The current browser-tools extension is already on the right side of the 2026 design curve. It has made several choices that are smarter than many contemporary AI browser stacks. + +The next leap is to shift from: + +- a browser control toolkit + +into: + +- a browser execution and verification device purpose-built for agents + +The most important changes are: + +- first-class assertions +- batch execution +- state diffs +- form intelligence +- session/page/frame modeling +- durable debug artifacts +- intent-aware semantic helpers + +If these are implemented well, browser-tools can become not just a useful extension, but a foundational AI-native capability for both: + +- **agentic browser use across the web** +- **automatic verification inside GSD workflows** + +--- + +## File Added + +This proposal is stored at: + +`agent/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md` diff --git a/src/resources/extensions/browser-tools/core.js b/src/resources/extensions/browser-tools/core.js new file mode 100644 index 000000000..016e209fa --- /dev/null +++ b/src/resources/extensions/browser-tools/core.js @@ -0,0 +1,1057 @@ +/** + * Runtime-neutral helper logic for browser-tools. + * + * Kept free of pi-specific imports so it can be exercised with node:test. + */ + +export function createActionTimeline(limit = 60) { + return { + limit, + nextId: 1, + entries: [], + }; +} + +export function beginAction(timeline, partial) { + const entry = { + id: timeline.nextId++, + tool: partial.tool, + paramsSummary: partial.paramsSummary ?? "", + startedAt: partial.startedAt ?? Date.now(), + finishedAt: null, + status: "running", + beforeUrl: partial.beforeUrl ?? "", + afterUrl: partial.afterUrl ?? "", + verificationSummary: partial.verificationSummary, + warningSummary: partial.warningSummary, + diffSummary: partial.diffSummary, + changed: partial.changed, + error: partial.error, + }; + timeline.entries.push(entry); + if (timeline.entries.length > timeline.limit) { + timeline.entries.splice(0, timeline.entries.length - timeline.limit); + } + return entry; +} + +export function finishAction(timeline, actionId, updates = {}) { + const entry = timeline.entries.find((item) => item.id === actionId); + if (!entry) return null; + Object.assign(entry, updates, { + finishedAt: updates.finishedAt ?? Date.now(), + status: updates.status ?? entry.status ?? "success", + afterUrl: updates.afterUrl ?? entry.afterUrl ?? "", + verificationSummary: updates.verificationSummary ?? entry.verificationSummary, + warningSummary: updates.warningSummary ?? entry.warningSummary, + diffSummary: updates.diffSummary ?? entry.diffSummary, + changed: updates.changed ?? entry.changed, + error: updates.error ?? entry.error, + }); + return entry; +} + +export function findAction(timeline, actionId) { + return timeline.entries.find((item) => item.id === actionId) ?? null; +} + +export function toActionParamsSummary(params) { + if (!params || typeof params !== "object") return ""; + const entries = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (typeof value === "string") { + entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`); + continue; + } + if (Array.isArray(value)) { + entries.push(`${key}=[${value.length}]`); + continue; + } + if (typeof value === "object") { + entries.push(`${key}={...}`); + continue; + } + entries.push(`${key}=${String(value)}`); + } + return entries.slice(0, 6).join(", "); +} + +export function diffCompactStates(before, after) { + const changes = []; + if (!before || !after) { + return { + changed: false, + changes: [], + summary: "Diff unavailable", + }; + } + + if (before.url !== after.url) { + changes.push({ type: "url", before: before.url, after: after.url }); + } + if (before.title !== after.title) { + changes.push({ type: "title", before: before.title, after: after.title }); + } + if (before.focus !== after.focus) { + changes.push({ type: "focus", before: before.focus, after: after.focus }); + } + if ((before.dialog?.count ?? 0) !== (after.dialog?.count ?? 0)) { + changes.push({ + type: "dialog_count", + before: before.dialog?.count ?? 0, + after: after.dialog?.count ?? 0, + }); + } + if ((before.dialog?.title ?? "") !== (after.dialog?.title ?? "")) { + changes.push({ + type: "dialog_title", + before: before.dialog?.title ?? "", + after: after.dialog?.title ?? "", + }); + } + + for (const key of ["landmarks", "buttons", "links", "inputs"]) { + const beforeValue = before.counts?.[key] ?? 0; + const afterValue = after.counts?.[key] ?? 0; + if (beforeValue !== afterValue) { + changes.push({ type: `count:${key}`, before: beforeValue, after: afterValue }); + } + } + + const beforeHeadings = JSON.stringify(before.headings ?? []); + const afterHeadings = JSON.stringify(after.headings ?? []); + if (beforeHeadings !== afterHeadings) { + changes.push({ + type: "headings", + before: before.headings ?? [], + after: after.headings ?? [], + }); + } + + const beforeBody = before.bodyText ?? ""; + const afterBody = after.bodyText ?? ""; + if (beforeBody !== afterBody) { + changes.push({ + type: "body_text", + before: beforeBody.slice(0, 120), + after: afterBody.slice(0, 120), + }); + } + + const changed = changes.length > 0; + const summary = changed + ? changes + .slice(0, 4) + .map((change) => { + if (change.type === "url") return `URL changed to ${change.after}`; + if (change.type === "title") return `title changed to ${change.after}`; + if (change.type === "focus") return `focus changed`; + if (change.type === "dialog_count") return `dialog count ${change.before}→${change.after}`; + if (change.type.startsWith("count:")) return `${change.type.slice(6)} ${change.before}→${change.after}`; + if (change.type === "headings") return "headings changed"; + if (change.type === "body_text") return "visible text changed"; + return `${change.type} changed`; + }) + .join("; ") + : "No meaningful browser-state change detected"; + + return { changed, changes, summary }; +} + +function normalizeString(value) { + return String(value ?? "").trim(); +} + +export function includesNeedle(haystack, needle) { + return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase()); +} + +// --------------------------------------------------------------------------- +// Threshold parsing for count-based assertions +// --------------------------------------------------------------------------- + +/** + * Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">="). + * @param {string} value + * @returns {{ op: string, n: number } | null} — null if malformed + */ +export function parseThreshold(value) { + if (value == null) return null; + const str = String(value).trim(); + if (str === "") return null; + const match = str.match(/^(>=|<=|==|>|<)?\s*(\d+)$/); + if (!match) return null; + const op = match[1] || ">="; + const n = parseInt(match[2], 10); + return { op, n }; +} + +/** + * Evaluate whether a count meets a parsed threshold. + * @param {number} count + * @param {{ op: string, n: number }} threshold + * @returns {boolean} + */ +export function meetsThreshold(count, threshold) { + switch (threshold.op) { + case ">=": return count >= threshold.n; + case "<=": return count <= threshold.n; + case "==": return count === threshold.n; + case ">": return count > threshold.n; + case "<": return count < threshold.n; + default: return false; + } +} + +/** + * Filter entries that occurred at or after a given action's start time. + * If sinceActionId is missing or the action isn't found, returns all entries. + * @param {Array<{ timestamp?: number }>} entries + * @param {number | undefined} sinceActionId + * @param {{ entries: Array<{ id: number, startedAt: number }> }} timeline + * @returns {Array} + */ +export function getEntriesSince(entries, sinceActionId, timeline) { + if (!entries || !Array.isArray(entries)) return []; + if (sinceActionId == null || !timeline) return entries; + const action = findAction(timeline, sinceActionId); + if (!action) return entries; + const since = action.startedAt; + return entries.filter((e) => (e.timestamp ?? 0) >= since); +} + +export function evaluateAssertionChecks({ checks, state }) { + const results = []; + const selectorStates = state.selectorStates ?? {}; + const consoleEntries = state.consoleEntries ?? []; + const networkEntries = state.networkEntries ?? []; + const allConsoleEntries = state.allConsoleEntries ?? state.consoleEntries ?? []; + const allNetworkEntries = state.allNetworkEntries ?? state.networkEntries ?? []; + const actionTimeline = state.actionTimeline ?? null; + + for (const check of checks) { + const selectorState = check.selector ? selectorStates[check.selector] ?? null : null; + let passed = false; + let actual; + let expected; + + switch (check.kind) { + case "url_contains": + actual = state.url ?? ""; + expected = check.value ?? ""; + passed = includesNeedle(actual, expected); + break; + case "title_contains": + actual = state.title ?? ""; + expected = check.value ?? ""; + passed = includesNeedle(actual, expected); + break; + case "text_visible": + actual = state.bodyText ?? ""; + expected = check.text ?? ""; + passed = includesNeedle(actual, expected); + break; + case "text_not_visible": + actual = state.bodyText ?? ""; + expected = check.text ?? ""; + passed = !includesNeedle(actual, expected); + break; + case "selector_visible": + actual = selectorState?.visible ?? false; + expected = true; + passed = actual === true; + break; + case "selector_hidden": + actual = selectorState?.visible ?? false; + expected = false; + passed = actual === false; + break; + case "value_equals": + actual = selectorState?.value ?? ""; + expected = check.value ?? ""; + passed = actual === expected; + break; + case "value_contains": + actual = selectorState?.value ?? ""; + expected = check.value ?? ""; + passed = includesNeedle(actual, expected); + break; + case "focused_matches": + actual = state.focus ?? ""; + expected = check.value ?? ""; + passed = includesNeedle(actual, expected); + break; + case "checked_equals": + actual = selectorState?.checked ?? null; + expected = !!check.checked; + passed = actual === expected; + break; + case "no_console_errors": + actual = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror").length; + expected = 0; + passed = actual === 0; + break; + case "no_failed_requests": + actual = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)).length; + expected = 0; + passed = actual === 0; + break; + + // --- S02: New structured network/console assertion kinds --- + + case "request_url_seen": { + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); + const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); + actual = matches.length > 0; + expected = true; + passed = actual === true; + break; + } + + case "response_status": { + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); + const statusNum = parseInt(check.value, 10); + const matches = filtered.filter( + (e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum + ); + actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`; + expected = `status=${check.value ?? ""}`; + passed = matches.length > 0; + break; + } + + case "console_message_matches": { + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); + const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); + actual = matches.length > 0; + expected = true; + passed = actual === true; + break; + } + + case "network_count": { + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); + const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); + const threshold = parseThreshold(check.value); + if (!threshold) { + actual = `invalid threshold: ${check.value}`; + expected = check.value ?? ""; + passed = false; + } else { + actual = `count=${matches.length}`; + expected = `${threshold.op}${threshold.n}`; + passed = meetsThreshold(matches.length, threshold); + } + break; + } + + case "console_count": { + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); + const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); + const threshold = parseThreshold(check.value); + if (!threshold) { + actual = `invalid threshold: ${check.value}`; + expected = check.value ?? ""; + passed = false; + } else { + actual = `count=${matches.length}`; + expected = `${threshold.op}${threshold.n}`; + passed = meetsThreshold(matches.length, threshold); + } + break; + } + + case "no_console_errors_since": { + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); + const errors = filtered.filter((e) => e.type === "error" || e.type === "pageerror"); + actual = errors.length; + expected = 0; + passed = errors.length === 0; + break; + } + + case "no_failed_requests_since": { + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); + const failures = filtered.filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400)); + actual = failures.length; + expected = 0; + passed = failures.length === 0; + break; + } + + default: + actual = "unsupported"; + expected = check.kind; + passed = false; + break; + } + + results.push({ + name: check.kind, + passed, + actual, + expected, + selector: check.selector, + text: check.text, + }); + } + + const failed = results.filter((result) => !result.passed); + const verified = failed.length === 0; + return { + verified, + checks: results, + summary: verified + ? `PASS (${results.length}/${results.length} checks)` + : `FAIL (${failed.length}/${results.length} checks failed)`, + agentHint: verified + ? "All assertion checks passed" + : failed[0] + ? `Investigate ${failed[0].name} (expected ${JSON.stringify(failed[0].expected)}, got ${JSON.stringify(failed[0].actual)})` + : "Assertion failed", + }; +} + +// --------------------------------------------------------------------------- +// Wait-condition validation +// --------------------------------------------------------------------------- + +/** + * All recognized wait conditions with their parameter requirements. + * Each entry: { needsValue: bool, valueLabel: string, needsThreshold?: bool } + */ +const WAIT_CONDITIONS = { + // Existing 5 conditions + selector_visible: { needsValue: true, valueLabel: "CSS selector" }, + selector_hidden: { needsValue: true, valueLabel: "CSS selector" }, + url_contains: { needsValue: true, valueLabel: "URL substring" }, + network_idle: { needsValue: false, valueLabel: "" }, + delay: { needsValue: true, valueLabel: "milliseconds as a string (e.g. '1000')" }, + + // New 6 conditions (S03) + text_visible: { needsValue: true, valueLabel: "text to search for" }, + text_hidden: { needsValue: true, valueLabel: "text to search for" }, + request_completed: { needsValue: true, valueLabel: "URL substring to match" }, + console_message: { needsValue: true, valueLabel: "message substring to match" }, + element_count: { needsValue: true, valueLabel: "CSS selector", needsThreshold: true }, + region_stable: { needsValue: true, valueLabel: "CSS selector" }, +}; + +/** + * Validate parameters for a browser_wait_for condition. + * @param {{ condition: string, value?: string, threshold?: string }} params + * @returns {null | { error: string }} — null if valid, structured error otherwise + */ +export function validateWaitParams(params) { + const { condition, value, threshold } = params ?? {}; + + if (!condition) { + return { error: "condition is required" }; + } + + const spec = WAIT_CONDITIONS[condition]; + if (!spec) { + const known = Object.keys(WAIT_CONDITIONS).join(", "); + return { error: `unknown condition "${condition}". Known conditions: ${known}` }; + } + + if (spec.needsValue && (!value || String(value).trim() === "")) { + return { error: `${condition} requires a value (${spec.valueLabel})` }; + } + + if (spec.needsThreshold && threshold != null && String(threshold).trim() !== "") { + const parsed = parseThreshold(threshold); + if (!parsed) { + return { error: `${condition} threshold is malformed: "${threshold}". Expected format: >=N, <=N, ==N, >N, ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0; + const windowKey = `__pw_region_stable_${safeKey}`; + + return `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return false; + const snapshot = el.innerHTML.length + '|' + el.childElementCount + '|' + el.innerText.length; + const prev = window[${JSON.stringify(windowKey)}]; + window[${JSON.stringify(windowKey)}] = snapshot; + if (prev === undefined) return false; + return snapshot === prev; +})()`; +} + +// --------------------------------------------------------------------------- +// Page Registry — pure-logic operations for multi-page/tab management +// --------------------------------------------------------------------------- + +/** + * Create a fresh page registry. + * @returns {{ pages: Array, activePageId: number | null, nextId: number }} + */ +export function createPageRegistry() { + return { pages: [], activePageId: null, nextId: 1 }; +} + +/** + * @typedef {{ id: number, page: any, title: string, url: string, opener: number | null }} PageEntry + */ + +/** + * Add a page to the registry. Assigns an auto-incrementing ID. + * @param {ReturnType} registry + * @param {{ page: any, title?: string, url?: string, opener?: number | null }} info + * @returns {PageEntry} + */ +export function registryAddPage(registry, { page, title = "", url = "", opener = null }) { + const entry = { id: registry.nextId++, page, title, url, opener }; + registry.pages.push(entry); + return entry; +} + +/** + * Remove a page from the registry by ID. + * If the removed page was active, falls back to the opener (if still present) + * or the last remaining page. + * Orphans any pages whose opener was the removed page (sets their opener to null). + * @param {ReturnType} registry + * @param {number} pageId + * @returns {{ removed: PageEntry, newActiveId: number | null }} + */ +export function registryRemovePage(registry, pageId) { + const idx = registry.pages.findIndex((p) => p.id === pageId); + if (idx === -1) { + const available = registry.pages.map((p) => p.id); + throw new Error( + `registryRemovePage: page ${pageId} not found. ` + + `Available page IDs: [${available.join(", ")}]. ` + + `Registry size: ${registry.pages.length}.` + ); + } + const [removed] = registry.pages.splice(idx, 1); + + // Orphan any pages whose opener was the removed page + for (const entry of registry.pages) { + if (entry.opener === pageId) { + entry.opener = null; + } + } + + let newActiveId = registry.activePageId; + if (registry.activePageId === pageId) { + if (registry.pages.length === 0) { + newActiveId = null; + } else if (removed.opener !== null && registry.pages.some((p) => p.id === removed.opener)) { + newActiveId = removed.opener; + } else { + newActiveId = registry.pages[registry.pages.length - 1].id; + } + registry.activePageId = newActiveId; + } + + return { removed, newActiveId }; +} + +/** + * Set the active page by ID. Throws if the page is not in the registry. + * @param {ReturnType} registry + * @param {number} pageId + */ +export function registrySetActive(registry, pageId) { + const entry = registry.pages.find((p) => p.id === pageId); + if (!entry) { + const available = registry.pages.map((p) => p.id); + throw new Error( + `registrySetActive: page ${pageId} not found. ` + + `Available page IDs: [${available.join(", ")}]. ` + + `Registry size: ${registry.pages.length}.` + ); + } + registry.activePageId = pageId; +} + +/** + * Get the active page entry. Throws if no active page or active page not found. + * @param {ReturnType} registry + * @returns {PageEntry} + */ +export function registryGetActive(registry) { + if (registry.activePageId === null) { + throw new Error( + `registryGetActive: no active page. ` + + `Registry contains ${registry.pages.length} page(s). ` + + `Page IDs: [${registry.pages.map((p) => p.id).join(", ")}].` + ); + } + const entry = registry.pages.find((p) => p.id === registry.activePageId); + if (!entry) { + throw new Error( + `registryGetActive: activePageId ${registry.activePageId} not found in registry. ` + + `Available page IDs: [${registry.pages.map((p) => p.id).join(", ")}]. ` + + `Registry size: ${registry.pages.length}. This indicates stale state.` + ); + } + return entry; +} + +/** + * Get a page entry by ID, or null if not found. + * @param {ReturnType} registry + * @param {number} pageId + * @returns {PageEntry | null} + */ +export function registryGetPage(registry, pageId) { + return registry.pages.find((p) => p.id === pageId) ?? null; +} + +/** + * List all pages (without the raw `page` reference). + * @param {ReturnType} registry + * @returns {Array<{ id: number, title: string, url: string, opener: number | null, isActive: boolean }>} + */ +export function registryListPages(registry) { + return registry.pages.map((entry) => ({ + id: entry.id, + title: entry.title, + url: entry.url, + opener: entry.opener, + isActive: entry.id === registry.activePageId, + })); +} + +// --------------------------------------------------------------------------- +// FIFO Bounded Log Pusher +// --------------------------------------------------------------------------- + +/** + * Create a push function that enforces FIFO eviction at push-time. + * @param {number} maxSize — maximum number of entries to retain + * @returns {(array: Array, entry: any) => void} + */ +export function createBoundedLogPusher(maxSize) { + return function push(array, entry) { + array.push(entry); + if (array.length > maxSize) { + array.splice(0, array.length - maxSize); + } + }; +} + +export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }) { + const results = []; + for (let i = 0; i < steps.length; i += 1) { + const step = steps[i]; + const result = await executeStep(step, i); + results.push(result); + if (result.ok === false && stopOnFailure) { + return { + ok: false, + stopReason: "step_failed", + failedStepIndex: i, + stepResults: results, + summary: `Stopped at step ${i + 1} (${step.action})`, + }; + } + } + return { + ok: true, + stopReason: null, + failedStepIndex: null, + stepResults: results, + summary: `Completed ${results.length} step(s)`, + }; +} + +// --------------------------------------------------------------------------- +// Snapshot Modes — semantic element filtering for browser_snapshot_refs +// --------------------------------------------------------------------------- + +/** + * Pre-defined snapshot modes that filter elements by semantic category. + * Each mode config defines which elements should be captured. + * + * Shape: { tags: string[], roles: string[], selectors: string[], + * ariaAttributes: string[], useInteractiveFilter: boolean, + * visibleOnly?: boolean, containerExpand?: boolean } + */ +export const SNAPSHOT_MODES = { + interactive: { + tags: [], + roles: [], + selectors: [], + ariaAttributes: [], + useInteractiveFilter: true, + }, + form: { + tags: ["input", "select", "textarea", "button", "fieldset", "label", "output", "datalist"], + roles: ["textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "slider", "spinbutton", "listbox", "option"], + selectors: ["[contenteditable]"], + ariaAttributes: [], + useInteractiveFilter: false, + }, + dialog: { + tags: ["dialog"], + roles: ["dialog", "alertdialog"], + selectors: ['[role="dialog"]', '[role="alertdialog"]'], + ariaAttributes: [], + useInteractiveFilter: false, + containerExpand: true, + }, + navigation: { + tags: ["a", "nav"], + roles: ["link", "navigation", "menubar", "menu", "menuitem"], + selectors: [], + ariaAttributes: [], + useInteractiveFilter: false, + }, + errors: { + tags: [], + roles: ["alert", "status"], + selectors: ['[aria-invalid="true"]', '[role="alert"]', '[role="status"]'], + ariaAttributes: ["aria-invalid", "aria-errormessage"], + useInteractiveFilter: false, + containerExpand: true, + }, + headings: { + tags: ["h1", "h2", "h3", "h4", "h5", "h6"], + roles: ["heading"], + selectors: [], + ariaAttributes: [], + useInteractiveFilter: false, + }, + visible_only: { + tags: [], + roles: [], + selectors: [], + ariaAttributes: [], + useInteractiveFilter: false, + visibleOnly: true, + }, +}; + +/** + * Get the snapshot mode config by name. + * @param {string} mode — mode name (e.g. "form", "dialog", "interactive") + * @returns {{ tags: string[], roles: string[], selectors: string[], ariaAttributes: string[], useInteractiveFilter: boolean, visibleOnly?: boolean, containerExpand?: boolean } | null} + */ +export function getSnapshotModeConfig(mode) { + return SNAPSHOT_MODES[mode] ?? null; +} + +// --------------------------------------------------------------------------- +// Fingerprint functions — structural identity for ref resolution +// --------------------------------------------------------------------------- + +/** + * Compute a content hash from visible text using djb2. + * Caller is expected to pre-truncate to ~200 chars and normalize whitespace. + * @param {string} text — visible text content + * @returns {string} — hex string hash, or "0" for empty input + */ +export function computeContentHash(text) { + if (!text) return "0"; + let h = 5381; + for (let i = 0; i < text.length; i++) { + h = ((h << 5) - h + text.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16); +} + +/** + * Compute a structural signature from tag, role, and immediate child tag names. + * Uses djb2 hash on the concatenated string `tag|role|child1,child2,...`. + * @param {string} tag — element tag name (lowercase) + * @param {string} role — ARIA role or empty string + * @param {string[]} childTags — array of immediate child tag names (lowercase) + * @returns {string} — hex string hash + */ +export function computeStructuralSignature(tag, role, childTags) { + const input = `${tag}|${role}|${childTags.join(",")}`; + let h = 5381; + for (let i = 0; i < input.length; i++) { + h = ((h << 5) - h + input.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16); +} + +/** + * Match two fingerprint objects by contentHash and structuralSignature. + * Returns true only when both fields are present on both objects and both match. + * @param {{ contentHash?: string, structuralSignature?: string }} stored + * @param {{ contentHash?: string, structuralSignature?: string }} candidate + * @returns {boolean} + */ +export function matchFingerprint(stored, candidate) { + if (!stored || !candidate) return false; + if (!stored.contentHash || !stored.structuralSignature) return false; + if (!candidate.contentHash || !candidate.structuralSignature) return false; + return stored.contentHash === candidate.contentHash && + stored.structuralSignature === candidate.structuralSignature; +} + +function formatDurationMs(entry) { + const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null; + const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null; + if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null; + return finishedAt - startedAt; +} + +function summarizeActionStatus(status) { + if (status === "error") return "error"; + if (status === "running") return "running"; + return "success"; +} + +function looksBoundedWarning(value) { + return /bounded .*history/i.test(String(value ?? "")); +} + +function uniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function formatTimelineEntries(entries = [], options = {}) { + const retained = options.retained ?? entries.length; + const totalRecorded = options.totalRecorded ?? retained; + const bounded = totalRecorded > retained; + + if (!entries.length) { + return { + entries: [], + retained, + totalRecorded, + bounded, + summary: "No browser actions recorded.", + }; + } + + const formattedEntries = entries.map((entry) => { + const status = summarizeActionStatus(entry.status); + const durationMs = formatDurationMs(entry); + const parts = [ + `#${entry.id ?? "?"}`, + entry.tool ?? "unknown_tool", + status, + ]; + + if (durationMs != null) parts.push(`${durationMs}ms`); + if (entry.paramsSummary) parts.push(entry.paramsSummary); + if (entry.error) parts.push(entry.error); + if (entry.verificationSummary) parts.push(entry.verificationSummary); + if (entry.diffSummary) parts.push(entry.diffSummary); + if (entry.warningSummary) parts.push(entry.warningSummary); + + return { + id: entry.id ?? null, + tool: entry.tool ?? "", + status, + durationMs, + beforeUrl: entry.beforeUrl ?? "", + afterUrl: entry.afterUrl ?? "", + line: parts.join(" | "), + }; + }); + + const summary = bounded + ? `Timeline: showing ${retained} of ${totalRecorded} recorded browser actions; older actions were discarded due to bounded history.` + : `Timeline: ${retained} browser action${retained === 1 ? "" : "s"} recorded.`; + + return { + entries: formattedEntries, + retained, + totalRecorded, + bounded, + summary, + }; +} + +export function buildFailureHypothesis(session = {}) { + const timelineEntries = session.actionTimeline?.entries ?? []; + const consoleEntries = session.consoleEntries ?? []; + const networkEntries = session.networkEntries ?? []; + const dialogEntries = session.dialogEntries ?? []; + const signals = []; + + for (const entry of timelineEntries) { + if (entry?.status !== "error") continue; + if (entry.tool === "browser_wait_for") { + signals.push({ + category: "wait", + source: `action#${entry.id ?? "?"}`, + detail: entry.error || entry.warningSummary || "Wait condition failed", + }); + continue; + } + if (entry.tool === "browser_assert") { + signals.push({ + category: "assert", + source: `action#${entry.id ?? "?"}`, + detail: entry.error || entry.verificationSummary || "Assertion failed", + }); + continue; + } + signals.push({ + category: "action", + source: `action#${entry.id ?? "?"}`, + detail: entry.error || `${entry.tool ?? "browser action"} failed`, + }); + } + + for (const entry of consoleEntries) { + if (entry?.type !== "error" && entry?.type !== "pageerror") continue; + signals.push({ + category: "console", + source: entry.type, + detail: entry.text || "Console error recorded", + }); + } + + for (const entry of networkEntries) { + const failed = entry?.failed || (typeof entry?.status === "number" && entry.status >= 400); + if (!failed) continue; + signals.push({ + category: "network", + source: entry.url || "network request", + detail: `${entry.url || "request"} failed${typeof entry?.status === "number" ? ` with ${entry.status}` : ""}`, + }); + } + + for (const entry of dialogEntries) { + signals.push({ + category: "dialog", + source: entry?.type || "dialog", + detail: entry?.message || "Dialog appeared during failure investigation", + }); + } + + const categories = uniqueStrings(signals.map((signal) => signal.category)); + const hasFailures = categories.length > 0; + const summary = hasFailures + ? `Recent failure signals detected across ${categories.join(", ")}.` + : "No recent failure signals detected."; + + return { + hasFailures, + categories, + summary, + signals, + }; +} + +export function summarizeBrowserSession(session = {}) { + const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] }; + const actionEntries = actionTimeline.entries ?? []; + const retainedActionCount = session.retainedActionCount ?? actionEntries.length; + const totalActionCount = session.totalActionCount ?? retainedActionCount; + const pages = session.pages ?? []; + const consoleEntries = session.consoleEntries ?? []; + const networkEntries = session.networkEntries ?? []; + const dialogEntries = session.dialogEntries ?? []; + + const actionStatusCounts = actionEntries.reduce( + (acc, entry) => { + const status = summarizeActionStatus(entry.status); + acc[status] = (acc[status] ?? 0) + 1; + return acc; + }, + { success: 0, error: 0, running: 0 }, + ); + + const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for"); + const assertEntries = actionEntries.filter((entry) => entry.tool === "browser_assert"); + const consoleErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror"); + const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)); + const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null; + + const caveats = []; + if (totalActionCount > retainedActionCount) { + caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`); + } + if ( + actionEntries.some((entry) => looksBoundedWarning(entry.warningSummary) || looksBoundedWarning(entry.error)) || + consoleEntries.some((entry) => looksBoundedWarning(entry.text) || looksBoundedWarning(entry.message)) || + consoleEntries.length > 0 + ) { + caveats.push("bounded console history may hide older console events."); + } + if (failedRequests.length > 0 || networkEntries.length > 0) { + caveats.push("bounded network history may hide older requests."); + } + + const failureHypothesis = buildFailureHypothesis(session); + + if (!actionEntries.length && pages.length === 0 && consoleEntries.length === 0 && networkEntries.length === 0 && dialogEntries.length === 0) { + return { + counts: { + pages: 0, + actions: { total: 0, retained: 0, success: 0, error: 0, running: 0 }, + waits: { total: 0, success: 0, error: 0, running: 0 }, + assertions: { total: 0, passed: 0, failed: 0, running: 0 }, + consoleErrors: 0, + failedRequests: 0, + dialogs: 0, + }, + activePage: null, + caveats: [], + failureHypothesis, + summary: "No browser session activity recorded.", + }; + } + + return { + counts: { + pages: pages.length, + actions: { + total: totalActionCount, + retained: retainedActionCount, + success: actionStatusCounts.success, + error: actionStatusCounts.error, + running: actionStatusCounts.running, + }, + waits: { + total: waitEntries.length, + success: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length, + error: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length, + running: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length, + }, + assertions: { + total: assertEntries.length, + passed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length, + failed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length, + running: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length, + }, + consoleErrors: consoleErrors.length, + failedRequests: failedRequests.length, + dialogs: dialogEntries.length, + }, + activePage: activePage + ? { + id: activePage.id ?? null, + title: activePage.title ?? "", + url: activePage.url ?? "", + } + : null, + caveats, + failureHypothesis, + summary: `Session: ${pages.length} page${pages.length === 1 ? "" : "s"}, ${totalActionCount} actions, ${waitEntries.length} wait${waitEntries.length === 1 ? "" : "s"}, ${assertEntries.length} assert${assertEntries.length === 1 ? "" : "s"}.${caveats.length ? ` ${caveats.join(" ")}` : ""}`, + }; +} diff --git a/src/resources/extensions/browser-tools/index.ts b/src/resources/extensions/browser-tools/index.ts new file mode 100644 index 000000000..c545360db --- /dev/null +++ b/src/resources/extensions/browser-tools/index.ts @@ -0,0 +1,4916 @@ +/** + * browser-tools — pi extension + * + * Gives the agent full browser interaction capabilities for verifying and testing + * UI work without requiring a human to look at the screen. + * + * Key design principles: + * - Every action returns feedback (accessibility snapshot, screenshots on navigate) + * - Errors include visual debugging (screenshots on failure, surfaced JS errors) + * - Smart waits (domcontentloaded + best-effort settle, not blocking networkidle) + * - 2x DPI screenshots for readable text + * - JPEG for viewport screenshots (smaller), PNG for element crops (transparency) + * - Auto-handles JS dialogs (alert/confirm/prompt) to prevent page freezes + * - Auto-switches to new tabs (popups, target="_blank") + * + * Architecture: + * - Single shared Browser + BrowserContext + Page per session + * - Console, network, and dialog events buffered in memory + * - Browser launched headed so the user can optionally watch + * - Cleaned up on session_shutdown + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import type { Browser, BrowserContext, Frame, Page } from "playwright"; +import { mkdir, stat, writeFile, copyFile } from "node:fs/promises"; +import path from "node:path"; +import { + beginAction, + createActionTimeline, + createBoundedLogPusher, + createPageRegistry, + diffCompactStates, + evaluateAssertionChecks, + finishAction, + findAction, + formatTimelineEntries, + getSnapshotModeConfig, + buildFailureHypothesis, + summarizeBrowserSession, + registryAddPage, + registryGetActive, + registryListPages, + registryRemovePage, + registrySetActive, + runBatchSteps, + SNAPSHOT_MODES, + toActionParamsSummary, + validateWaitParams, + createRegionStableScript, + parseThreshold, + meetsThreshold, + includesNeedle, +} from "./core.js"; + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +let browser: Browser | null = null; +let context: BrowserContext | null = null; +const pageRegistry = createPageRegistry(); +let activeFrame: Frame | null = null; +const logPusher = createBoundedLogPusher(1000); + +interface ConsoleEntry { + type: string; + text: string; + timestamp: number; + url: string; + pageId: number; +} + +interface NetworkEntry { + method: string; + url: string; + status: number | null; + resourceType: string; + timestamp: number; + failed: boolean; + failureText?: string; + responseBody?: string; // Only captured for 4xx/5xx responses, truncated to 2000 chars + pageId: number; +} + +let consoleLogs: ConsoleEntry[] = []; +let networkLogs: NetworkEntry[] = []; + +interface DialogEntry { + type: string; // "alert" | "confirm" | "prompt" | "beforeunload" + message: string; + timestamp: number; + url: string; + defaultValue?: string; // For prompt dialogs + accepted: boolean; // Whether we auto-accepted or dismissed + pageId: number; +} + +let dialogLogs: DialogEntry[] = []; + +const pendingCriticalRequestsByPage = new WeakMap(); + +interface RefNode { + ref: string; + tag: string; + role: string; + name: string; + selectorHints: string[]; + isVisible: boolean; + isEnabled: boolean; + xpathOrPath: string; + href?: string; + type?: string; + path: number[]; + contentHash?: string; + structuralSignature?: string; + nearestHeading?: string; + formOwnership?: string; +} + +interface RefMetadata { + url: string; + timestamp: number; + selectorScope?: string; + interactiveOnly: boolean; + limit: number; + version: number; + frameContext?: string; // Records which frame the snapshot was taken in (name or URL), undefined = main page + mode?: string; // Snapshot mode used (e.g. "form", "dialog", "navigation"), undefined = no mode (legacy interactiveOnly behavior) +} + +let currentRefMap: Record = {}; +let refVersion = 0; +let refMetadata: RefMetadata | null = null; +const actionTimeline = createActionTimeline(60); + +interface CompactSelectorState { + exists: boolean; + visible: boolean; + value: string; + checked: boolean | null; + text: string; +} + +interface CompactPageState { + url: string; + title: string; + focus: string; + headings: string[]; + bodyText: string; + counts: { + landmarks: number; + buttons: number; + links: number; + inputs: number; + }; + dialog: { + count: number; + title: string; + }; + selectorStates: Record; +} + +let lastActionBeforeState: CompactPageState | null = null; +let lastActionAfterState: CompactPageState | null = null; + +const ARTIFACT_ROOT = path.resolve(process.cwd(), ".artifacts", "browser"); +const HAR_FILENAME = "session.har"; + +interface TraceSessionState { + startedAt: number; + name: string; + title?: string; + path?: string; +} + +interface HarState { + enabled: boolean; + configuredAtContextCreation: boolean; + path: string | null; + exportCount: number; + lastExportedPath: string | null; + lastExportedAt: number | null; +} + +let sessionStartedAt: number | null = null; +let sessionArtifactDir: string | null = null; +let activeTraceSession: TraceSessionState | null = null; +let harState: HarState = { + enabled: false, + configuredAtContextCreation: false, + path: null, + exportCount: 0, + lastExportedPath: null, + lastExportedAt: null, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isCriticalResourceType(resourceType: string): boolean { + return resourceType === "document" || resourceType === "fetch" || resourceType === "xhr"; +} + +function updatePendingCriticalRequests(p: Page, delta: number): void { + const current = pendingCriticalRequestsByPage.get(p) ?? 0; + pendingCriticalRequestsByPage.set(p, Math.max(0, current + delta)); +} + +function getPendingCriticalRequests(p: Page): number { + return pendingCriticalRequestsByPage.get(p) ?? 0; +} + +/** Attach all event listeners to a page. Called on initial page and new tabs. */ +function attachPageListeners(p: Page, pageId: number): void { + pendingCriticalRequestsByPage.set(p, 0); + + // Console messages + p.on("console", (msg) => { + logPusher(consoleLogs, { + type: msg.type(), + text: msg.text(), + timestamp: Date.now(), + url: p.url(), + pageId, + }); + }); + + // Uncaught JS errors + p.on("pageerror", (err) => { + logPusher(consoleLogs, { + type: "pageerror", + text: err.message, + timestamp: Date.now(), + url: p.url(), + pageId, + }); + }); + + // Network requests — start/completed/failed + p.on("request", (request) => { + if (isCriticalResourceType(request.resourceType())) { + updatePendingCriticalRequests(p, 1); + } + }); + + p.on("requestfinished", async (request) => { + if (isCriticalResourceType(request.resourceType())) { + updatePendingCriticalRequests(p, -1); + } + try { + const response = await request.response(); + const status = response?.status() ?? null; + const entry: NetworkEntry = { + method: request.method(), + url: request.url(), + status, + resourceType: request.resourceType(), + timestamp: Date.now(), + failed: false, + pageId, + }; + if (response && status !== null && status >= 400) { + try { + const body = await response.text(); + entry.responseBody = body.slice(0, 2000); + } catch {} + } + logPusher(networkLogs, entry); + } catch {} + }); + + p.on("requestfailed", (request) => { + if (isCriticalResourceType(request.resourceType())) { + updatePendingCriticalRequests(p, -1); + } + logPusher(networkLogs, { + method: request.method(), + url: request.url(), + status: null, + resourceType: request.resourceType(), + timestamp: Date.now(), + failed: true, + failureText: request.failure()?.errorText ?? "Unknown failure", + pageId, + }); + }); + + // Auto-handle JS dialogs (alert, confirm, prompt, beforeunload) + p.on("dialog", async (dialog) => { + logPusher(dialogLogs, { + type: dialog.type(), + message: dialog.message(), + timestamp: Date.now(), + url: p.url(), + defaultValue: dialog.defaultValue() || undefined, + accepted: true, + pageId, + }); + // Auto-accept all dialogs to prevent page freezes + await dialog.accept().catch(() => {}); + }); + + // Frame detach handler — clears activeFrame if the selected frame detaches + p.on("framedetached", (frame) => { + if (activeFrame === frame) activeFrame = null; + }); + + // Page close handler — removes page from registry and handles active fallback + p.on("close", () => { + try { + registryRemovePage(pageRegistry, pageId); + } catch { + // Page already removed (e.g. during closeBrowser) + } + }); +} + +async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserContext; page: Page }> { + if (browser && context) { + return { browser, context, page: getActivePage() }; + } + + const startedAt = ensureSessionStartedAt(); + const artifactDir = await ensureSessionArtifactDir(); + const sessionHarPath = path.join(artifactDir, HAR_FILENAME); + harState = { + enabled: true, + configuredAtContextCreation: true, + path: sessionHarPath, + exportCount: 0, + lastExportedPath: null, + lastExportedAt: null, + }; + + // Lazy import so playwright is only loaded when actually needed + const { chromium } = await import("playwright"); + + browser = await chromium.launch({ headless: false }); + context = await browser.newContext({ + deviceScaleFactor: 2, + viewport: { width: 1280, height: 800 }, + recordHar: { + path: sessionHarPath, + mode: "minimal", + content: "omit", + }, + }); + sessionStartedAt = startedAt; + sessionArtifactDir = artifactDir; + const initialPage = await context.newPage(); + const pageEntry = registryAddPage(pageRegistry, { + page: initialPage, + title: await initialPage.title().catch(() => ""), + url: initialPage.url(), + opener: null, + }); + registrySetActive(pageRegistry, pageEntry.id); + attachPageListeners(initialPage, pageEntry.id); + + // Register new pages (popups, target="_blank", window.open) but do NOT auto-switch + context.on("page", (newPage) => { + // Determine opener page ID — find which registry page opened this one + const openerPage = newPage.opener(); + let openerId: number | null = null; + if (openerPage) { + const openerEntry = pageRegistry.pages.find((e: any) => e.page === openerPage); + if (openerEntry) openerId = openerEntry.id; + } + const entry = registryAddPage(pageRegistry, { + page: newPage, + title: "", + url: newPage.url(), + opener: openerId, + }); + attachPageListeners(newPage, entry.id); + // Update title once loaded + newPage.waitForLoadState("domcontentloaded", { timeout: 5000 }) + .then(() => newPage.title()) + .then((title) => { entry.title = title; }) + .catch(() => {}); + }); + + return { browser, context, page: getActivePage() }; +} + +/** Get the currently active page from the registry. */ +function getActivePage(): Page { + return registryGetActive(pageRegistry).page; +} + +/** Get the active target — returns the selected frame if one is active, otherwise the active page. */ +function getActiveTarget(): Page | Frame { + return activeFrame ?? getActivePage(); +} + +/** Safe accessor for error handling — returns the active page or null if unavailable. */ +function getActivePageOrNull(): Page | null { + try { + return getActivePage(); + } catch { + return null; + } +} + +async function closeBrowser(): Promise { + if (browser) { + await browser.close().catch(() => {}); + } + browser = null; + context = null; + pageRegistry.pages = []; + pageRegistry.activePageId = null; + pageRegistry.nextId = 1; + activeFrame = null; + consoleLogs = []; + networkLogs = []; + dialogLogs = []; + currentRefMap = {}; + refVersion = 0; + refMetadata = null; + lastActionBeforeState = null; + lastActionAfterState = null; + actionTimeline.entries = []; + actionTimeline.nextId = 1; + sessionStartedAt = null; + sessionArtifactDir = null; + activeTraceSession = null; + harState = { + enabled: false, + configuredAtContextCreation: false, + path: null, + exportCount: 0, + lastExportedPath: null, + lastExportedAt: null, + }; +} + +function truncateText(text: string): string { + const result = truncateHead(text, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + if (result.truncated) { + return ( + result.content + + `\n\n[Output truncated: ${result.outputLines}/${result.totalLines} lines shown]` + ); + } + return result.content; +} + +function formatArtifactTimestamp(timestamp: number): string { + return new Date(timestamp).toISOString().replace(/[:.]/g, "-"); +} + +async function ensureDir(dirPath: string): Promise { + await mkdir(dirPath, { recursive: true }); + return dirPath; +} + +async function writeArtifactFile(filePath: string, content: string | Uint8Array): Promise<{ path: string; bytes: number }> { + await ensureDir(path.dirname(filePath)); + await writeFile(filePath, content); + const fileStat = await stat(filePath); + return { path: filePath, bytes: fileStat.size }; +} + +async function copyArtifactFile(sourcePath: string, destinationPath: string): Promise<{ path: string; bytes: number }> { + await ensureDir(path.dirname(destinationPath)); + await copyFile(sourcePath, destinationPath); + const fileStat = await stat(destinationPath); + return { path: destinationPath, bytes: fileStat.size }; +} + +function ensureSessionStartedAt(): number { + if (!sessionStartedAt) sessionStartedAt = Date.now(); + return sessionStartedAt; +} + +async function ensureSessionArtifactDir(): Promise { + if (sessionArtifactDir) { + await ensureDir(sessionArtifactDir); + return sessionArtifactDir; + } + const startedAt = ensureSessionStartedAt(); + sessionArtifactDir = path.join(ARTIFACT_ROOT, `${formatArtifactTimestamp(startedAt)}-session`); + await ensureDir(sessionArtifactDir); + return sessionArtifactDir; +} + +function buildSessionArtifactPath(filename: string): string { + if (!sessionArtifactDir) { + throw new Error("browser session artifact directory is not initialized"); + } + return path.join(sessionArtifactDir, filename); +} + +function getActivePageMetadata() { + const activeEntry = pageRegistry.activePageId !== null + ? pageRegistry.pages.find((entry: any) => entry.id === pageRegistry.activePageId) ?? null + : null; + return { + id: activeEntry?.id ?? null, + title: activeEntry?.title ?? "", + url: activeEntry?.url ?? "", + }; +} + +function getActiveFrameMetadata() { + if (!activeFrame) { + return { name: null, url: null }; + } + return { + name: activeFrame.name() || null, + url: activeFrame.url() || null, + }; +} + +function getSessionArtifactMetadata() { + return { + artifactRoot: ARTIFACT_ROOT, + sessionStartedAt, + sessionArtifactDir, + activeTraceSession, + harState: { ...harState }, + activePage: getActivePageMetadata(), + activeFrame: getActiveFrameMetadata(), + }; +} + +function sanitizeArtifactName(value: string, fallback: string): string { + const sanitized = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || fallback; +} + +async function getLivePagesSnapshot() { + await ensureBrowser(); + for (const entry of pageRegistry.pages) { + try { + entry.title = await entry.page.title(); + entry.url = entry.page.url(); + } catch { + // Page may have been closed between snapshots. + } + } + return registryListPages(pageRegistry); +} + +async function resolveAccessibilityScope(selector?: string): Promise<{ selector?: string; scope: string; source: string }> { + if (selector?.trim()) { + return { selector: selector.trim(), scope: `selector:${selector.trim()}`, source: "explicit_selector" }; + } + const target = getActiveTarget(); + const dialogCount = await countOpenDialogs(target).catch(() => 0); + if (dialogCount > 0) { + return { selector: '[role="dialog"]:not([hidden]),dialog[open]', scope: "active dialog", source: "active_dialog" }; + } + if (activeFrame) { + return { selector: "body", scope: activeFrame.name() ? `active frame:${activeFrame.name()}` : "active frame", source: "active_frame" }; + } + return { selector: "body", scope: "full page", source: "full_page" }; +} + +async function captureAccessibilityMarkdown(selector?: string): Promise<{ snapshot: string; scope: string; source: string }> { + const target = getActiveTarget(); + const scopeInfo = await resolveAccessibilityScope(selector); + const locator = target.locator(scopeInfo.selector ?? "body").first(); + const snapshot = await locator.ariaSnapshot(); + return { snapshot, scope: scopeInfo.scope, source: scopeInfo.source }; +} + +function beginTrackedAction(tool: string, params: unknown, beforeUrl: string) { + return beginAction(actionTimeline, { + tool, + paramsSummary: toActionParamsSummary(params), + beforeUrl, + }); +} + +function finishTrackedAction( + actionId: number, + updates: { + status: "success" | "error"; + afterUrl?: string; + verificationSummary?: string; + warningSummary?: string; + diffSummary?: string; + changed?: boolean; + error?: string; + beforeState?: CompactPageState; + afterState?: CompactPageState; + } +) { + return finishAction(actionTimeline, actionId, updates); +} + +function getSinceTimestamp(sinceActionId?: number): number { + if (!sinceActionId) return 0; + const action = findAction(actionTimeline, sinceActionId); + if (!action) return 0; + return action.startedAt ?? 0; +} + +function getConsoleEntriesSince(sinceActionId?: number): ConsoleEntry[] { + const since = getSinceTimestamp(sinceActionId); + return consoleLogs.filter((entry) => entry.timestamp >= since); +} + +function getNetworkEntriesSince(sinceActionId?: number): NetworkEntry[] { + const since = getSinceTimestamp(sinceActionId); + return networkLogs.filter((entry) => entry.timestamp >= since); +} + +async function captureCompactPageState( + p: Page, + options: { selectors?: string[]; includeBodyText?: boolean; target?: Page | Frame } = {} +): Promise { + const selectors = Array.from(new Set((options.selectors ?? []).filter(Boolean))); + const target = options.target ?? p; + const domState = await target.evaluate(({ selectors, includeBodyText }) => { + const selectorStates: Record = {}; + for (const selector of selectors) { + let el: Element | null = null; + try { + el = document.querySelector(selector); + } catch { + el = null; + } + if (!el) { + selectorStates[selector] = { + exists: false, + visible: false, + value: "", + checked: null, + text: "", + }; + continue; + } + const htmlEl = el as HTMLElement; + const style = window.getComputedStyle(htmlEl); + const rect = htmlEl.getBoundingClientRect(); + const visible = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + const input = el as HTMLInputElement; + selectorStates[selector] = { + exists: true, + visible, + value: + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + el instanceof HTMLSelectElement + ? el.value + : htmlEl.getAttribute("value") || "", + checked: el instanceof HTMLInputElement && ["checkbox", "radio"].includes(input.type) ? input.checked : null, + text: (htmlEl.innerText || htmlEl.textContent || "").trim().replace(/\s+/g, " ").slice(0, 160), + }; + } + + const focused = document.activeElement as HTMLElement | null; + const focusedDesc = focused && focused !== document.body && focused !== document.documentElement + ? `${focused.tagName.toLowerCase()}${focused.id ? '#' + focused.id : ''}${focused.getAttribute('aria-label') ? ' "' + focused.getAttribute('aria-label') + '"' : ''}` + : ""; + const headings = Array.from(document.querySelectorAll('h1,h2,h3')).slice(0, 5).map((h) => (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80)); + const dialog = document.querySelector('[role="dialog"]:not([hidden]),dialog[open]'); + const dialogTitle = dialog?.querySelector('[role="heading"],[aria-label]')?.textContent?.trim().slice(0, 80) ?? ""; + const bodyText = includeBodyText + ? (document.body?.innerText || document.body?.textContent || "").trim().replace(/\s+/g, ' ').slice(0, 4000) + : ""; + return { + url: window.location.href, + title: document.title, + focus: focusedDesc, + headings, + bodyText, + counts: { + landmarks: document.querySelectorAll('[role="main"],[role="banner"],[role="navigation"],[role="contentinfo"],[role="complementary"],[role="search"],[role="form"],[role="dialog"],[role="alert"],main,header,nav,footer,aside,section,form,dialog').length, + buttons: document.querySelectorAll('button,[role="button"]').length, + links: document.querySelectorAll('a[href]').length, + inputs: document.querySelectorAll('input,textarea,select').length, + }, + dialog: { + count: document.querySelectorAll('[role="dialog"]:not([hidden]),dialog[open]').length, + title: dialogTitle, + }, + selectorStates, + }; + }, { selectors, includeBodyText: options.includeBodyText === true }); + // URL and title always come from the Page, not the frame + return { ...domState, url: p.url(), title: await p.title() }; +} + +function formatCompactStateSummary(state: CompactPageState): string { + const lines: string[] = []; + lines.push(`Title: ${state.title}`); + lines.push(`URL: ${state.url}`); + lines.push(`Elements: ${state.counts.landmarks} landmarks, ${state.counts.buttons} buttons, ${state.counts.links} links, ${state.counts.inputs} inputs`); + if (state.headings.length > 0) { + lines.push("Headings: " + state.headings.map((text, index) => `H${index + 1} \"${text}\"`).join(", ")); + } + if (state.focus) { + lines.push(`Focused: ${state.focus}`); + } + if (state.dialog.title) { + lines.push(`Active dialog: "${state.dialog.title}"`); + } + lines.push("Use browser_find for targeted discovery, browser_assert for verification, or browser_get_accessibility_tree for full detail."); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Post-action helpers +// --------------------------------------------------------------------------- + +/** Lightweight page summary after an action. Returns ~50-150 tokens instead of full tree. */ +async function postActionSummary(p: Page, target?: Page | Frame): Promise { + try { + const state = await captureCompactPageState(p, { target }); + return formatCompactStateSummary(state); + } catch { + return "[summary unavailable]"; + } +} + +/** Capture a JPEG screenshot for error debugging. Returns base64 or null. */ +async function captureErrorScreenshot(p: Page | null): Promise<{ data: string; mimeType: string } | null> { + if (!p) return null; + try { + const buf = await p.screenshot({ type: "jpeg", quality: 60 }); + return { data: buf.toString("base64"), mimeType: "image/jpeg" }; + } catch { + return null; + } +} + +/** + * Compact, action-relevant warnings for the current page origin. + * Full diagnostics stay pull-based via browser_get_console_logs/network_logs/dialog_logs. + */ +function getRecentErrors(pageUrl: string): string { + const parts: string[] = []; + const now = Date.now(); + const since = now - 12_000; + + const toOrigin = (url: string): string | null => { + try { + return new URL(url).origin; + } catch { + return null; + } + }; + const pageOrigin = toOrigin(pageUrl); + const sameOrigin = (url: string): boolean => !pageOrigin || toOrigin(url) === pageOrigin; + + const summarize = (items: string[], max: number): string[] => { + const counts = new Map(); + const order: string[] = []; + for (const item of items) { + if (!counts.has(item)) order.push(item); + counts.set(item, (counts.get(item) ?? 0) + 1); + } + return order.slice(0, max).map((item) => { + const count = counts.get(item) ?? 1; + return count > 1 ? `${item} (x${count})` : item; + }); + }; + + const jsWarnings = consoleLogs + .filter((e) => (e.type === "error" || e.type === "pageerror") && e.timestamp >= since && sameOrigin(e.url)) + .map((e) => e.text.slice(0, 120)); + if (jsWarnings.length > 0) { + parts.push("JS: " + summarize(jsWarnings, 2).join(" | ")); + } + + const actionableStatus = new Set([401, 403, 404, 408, 409, 422, 429]); + const actionableTypes = new Set(["document", "fetch", "xhr", "script"]); + const netWarnings = networkLogs + .filter((e) => e.timestamp >= since && sameOrigin(e.url)) + .filter((e) => { + if (e.failed) return actionableTypes.has(e.resourceType); + if (e.status === null) return false; + if (e.status >= 500) return true; + return actionableStatus.has(e.status) && actionableTypes.has(e.resourceType); + }) + .map((e) => { + if (e.failed) return `${e.method} ${e.resourceType} FAILED`; + return `${e.method} ${e.resourceType} ${e.status}`; + }); + if (netWarnings.length > 0) { + parts.push("Network: " + summarize(netWarnings, 2).join(" | ")); + } + + const dialogWarnings = dialogLogs + .filter((e) => e.timestamp >= since && sameOrigin(e.url)) + .map((e) => `${e.type}: ${e.message.slice(0, 80)}`); + if (dialogWarnings.length > 0) { + parts.push("Dialogs: " + summarize(dialogWarnings, 1).join(" | ")); + } + + if (parts.length === 0) return ""; + return `\n\nWarnings: ${parts.join("; ")}\nUse browser_get_console_logs/browser_get_network_logs for full diagnostics.`; +} + +interface AdaptiveSettleOptions { + timeoutMs?: number; + pollMs?: number; + quietWindowMs?: number; + checkFocusStability?: boolean; +} + +interface AdaptiveSettleDetails { + settleMode: "adaptive"; + settleMs: number; + settleReason: "dom_quiet" | "url_changed_then_quiet" | "timeout_fallback"; + settlePolls: number; +} + +async function ensureMutationCounter(p: Page): Promise { + await p.evaluate(() => { + const key = "__piMutationCounter" as const; + const installedKey = "__piMutationCounterInstalled" as const; + const w = window as unknown as Record; + if (typeof w[key] !== "number") w[key] = 0; + if (w[installedKey]) return; + const observer = new MutationObserver(() => { + const current = typeof w[key] === "number" ? (w[key] as number) : 0; + w[key] = current + 1; + }); + observer.observe(document.documentElement || document.body, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + w[installedKey] = true; + }); +} + +async function readMutationCounter(p: Page): Promise { + try { + return await p.evaluate(() => { + const w = window as unknown as Record; + const value = w.__piMutationCounter; + return typeof value === "number" ? value : 0; + }); + } catch { + return 0; + } +} + +async function readFocusedDescriptor(target: Page | Frame): Promise { + try { + return await target.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el || el === document.body || el === document.documentElement) return ""; + const id = el.id ? `#${el.id}` : ""; + const role = el.getAttribute("role") || ""; + const name = (el.getAttribute("aria-label") || el.getAttribute("name") || "").trim(); + return `${el.tagName.toLowerCase()}${id}|${role}|${name}`; + }); + } catch { + return ""; + } +} + +async function settleAfterActionAdaptive( + p: Page, + opts: AdaptiveSettleOptions = {} +): Promise { + const timeoutMs = Math.max(150, opts.timeoutMs ?? 500); + const pollMs = Math.min(100, Math.max(20, opts.pollMs ?? 40)); + const quietWindowMs = Math.max(60, opts.quietWindowMs ?? 100); + const checkFocus = opts.checkFocusStability ?? false; + + const startedAt = Date.now(); + let polls = 0; + let sawUrlChange = false; + let lastActivityAt = startedAt; + let previousUrl = p.url(); + + await ensureMutationCounter(p).catch(() => {}); + let previousMutationCount = await readMutationCounter(p); + let previousFocus = checkFocus ? await readFocusedDescriptor(p) : ""; + + while (Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, pollMs)); + polls += 1; + const now = Date.now(); + + const currentUrl = p.url(); + if (currentUrl !== previousUrl) { + sawUrlChange = true; + previousUrl = currentUrl; + lastActivityAt = now; + } + + const currentMutationCount = await readMutationCounter(p); + if (currentMutationCount > previousMutationCount) { + previousMutationCount = currentMutationCount; + lastActivityAt = now; + } + + if (checkFocus) { + const currentFocus = await readFocusedDescriptor(p); + if (currentFocus !== previousFocus) { + previousFocus = currentFocus; + lastActivityAt = now; + } + } + + const pendingCritical = getPendingCriticalRequests(p); + if (pendingCritical > 0) { + lastActivityAt = now; + continue; + } + + if (now - lastActivityAt >= quietWindowMs) { + return { + settleMode: "adaptive", + settleMs: now - startedAt, + settleReason: sawUrlChange ? "url_changed_then_quiet" : "dom_quiet", + settlePolls: polls, + }; + } + } + + return { + settleMode: "adaptive", + settleMs: Date.now() - startedAt, + settleReason: "timeout_fallback", + settlePolls: polls, + }; +} + +interface ParsedRefSpec { + key: string; + version: number | null; + display: string; +} + +function parseRef(input: string): ParsedRefSpec { + const trimmed = input.trim().toLowerCase(); + const token = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + const versioned = token.match(/^v(\d+):(e\d+)$/); + if (versioned) { + const version = parseInt(versioned[1], 10); + const key = versioned[2]; + return { key, version, display: `@v${version}:${key}` }; + } + return { key: token, version: null, display: `@${token}` }; +} + +function formatVersionedRef(version: number, key: string): string { + return `@v${version}:${key}`; +} + +function staleRefGuidance(refDisplay: string, reason: string): string { + return `Ref ${refDisplay} could not be resolved (${reason}). The ref is likely stale after DOM/navigation changes. Call browser_snapshot_refs again to refresh refs.`; +} + +interface VerificationCheck { + name: string; + passed: boolean; + value?: unknown; + expected?: unknown; +} + +interface VerificationResult { + verified: boolean; + checks: VerificationCheck[]; + verificationSummary: string; + retryHint?: string; +} + +interface ClickTargetStateSnapshot { + exists: boolean; + ariaExpanded: string | null; + ariaPressed: string | null; + ariaSelected: string | null; + open: boolean | null; +} + +function verificationFromChecks(checks: VerificationCheck[], retryHint?: string): VerificationResult { + const passedChecks = checks.filter((check) => check.passed).map((check) => check.name); + const verified = passedChecks.length > 0; + return { + verified, + checks, + verificationSummary: verified + ? `PASS (${passedChecks.join(", ")})` + : "SOFT-FAIL (no observable state change)", + retryHint: verified ? undefined : retryHint, + }; +} + +function verificationLine(verification: VerificationResult): string { + return `Verification: ${verification.verificationSummary}`; +} + +interface BrowserAssertionCheckInput { + kind: string; + selector?: string; + text?: string; + value?: string; + checked?: boolean; + sinceActionId?: number; +} + +async function collectAssertionState( + p: Page, + checks: BrowserAssertionCheckInput[], + target?: Page | Frame +): Promise<{ + url: string; + title: string; + bodyText: string; + focus: string; + selectorStates: Record; + consoleEntries: ConsoleEntry[]; + networkEntries: NetworkEntry[]; + allConsoleEntries: ConsoleEntry[]; + allNetworkEntries: NetworkEntry[]; + actionTimeline: ReturnType; +}> { + const selectors = checks.map((check) => check.selector).filter((value): value is string => !!value); + const compactState = await captureCompactPageState(p, { selectors, includeBodyText: true, target }); + const sinceActionId = checks.reduce((max, check) => { + if (check.sinceActionId === undefined) return max; + if (max === undefined) return check.sinceActionId; + return Math.max(max, check.sinceActionId); + }, undefined); + return { + url: compactState.url, + title: compactState.title, + bodyText: compactState.bodyText, + focus: compactState.focus, + selectorStates: compactState.selectorStates, + consoleEntries: getConsoleEntriesSince(sinceActionId), + networkEntries: getNetworkEntriesSince(sinceActionId), + allConsoleEntries: consoleLogs, + allNetworkEntries: networkLogs, + actionTimeline: actionTimeline, + }; +} + +function formatAssertionText(result: ReturnType): string { + const lines = [result.summary]; + for (const check of result.checks.slice(0, 8)) { + lines.push(`- ${check.passed ? "PASS" : "FAIL"} ${check.name}: expected ${JSON.stringify(check.expected)}, got ${JSON.stringify(check.actual)}`); + } + lines.push(`Hint: ${result.agentHint}`); + return lines.join("\n"); +} + +function formatDiffText(diff: ReturnType): string { + const lines = [diff.summary]; + for (const change of diff.changes.slice(0, 8)) { + lines.push(`- ${change.type}: ${JSON.stringify(change.before ?? null)} → ${JSON.stringify(change.after ?? null)}`); + } + return lines.join("\n"); +} + +function getUrlHash(url: string): string { + try { + return new URL(url).hash || ""; + } catch { + return ""; + } +} + +async function countOpenDialogs(target: Page | Frame): Promise { + try { + return await target.evaluate(() => + document.querySelectorAll('[role="dialog"]:not([hidden]),dialog[open]').length + ); + } catch { + return 0; + } +} + +async function captureClickTargetState(target: Page | Frame, selector: string): Promise { + try { + return await target.evaluate((sel) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) { + return { + exists: false, + ariaExpanded: null, + ariaPressed: null, + ariaSelected: null, + open: null, + }; + } + return { + exists: true, + ariaExpanded: el.getAttribute("aria-expanded"), + ariaPressed: el.getAttribute("aria-pressed"), + ariaSelected: el.getAttribute("aria-selected"), + open: el instanceof HTMLDialogElement ? el.open : el.getAttribute("open") !== null, + }; + }, selector); + } catch { + return { + exists: false, + ariaExpanded: null, + ariaPressed: null, + ariaSelected: null, + open: null, + }; + } +} + +async function readInputLikeValue(target: Page | Frame, selector?: string): Promise { + try { + return await target.evaluate((sel) => { + const resolveTarget = (): Element | null => { + if (sel) return document.querySelector(sel); + const active = document.activeElement; + if (!active || active === document.body || active === document.documentElement) return null; + return active; + }; + + const target = resolveTarget(); + if (!target) return null; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return target.value; + } + if (target instanceof HTMLSelectElement) { + return target.value; + } + if ((target as HTMLElement).isContentEditable) { + return (target.textContent ?? "").trim(); + } + return (target as HTMLElement).getAttribute("value"); + }, selector); + } catch { + return null; + } +} + +function firstErrorLine(err: unknown): string { + const message = typeof err === "object" && err && "message" in err + ? String((err as { message?: unknown }).message ?? "") + : String(err ?? "unknown error"); + return message.split("\n")[0] || "unknown error"; +} + +async function buildRefSnapshot( + target: Page | Frame, + options: { selector?: string; interactiveOnly: boolean; limit: number; mode?: string } +): Promise>> { + // Resolve mode config in Node context and serialize it as plain data for the evaluate callback + const modeConfig = options.mode ? getSnapshotModeConfig(options.mode) : null; + return await target.evaluate(({ selector, interactiveOnly, limit, modeConfig: mc }) => { + const root = selector ? document.querySelector(selector) : document.body; + if (!root) { + throw new Error(`Selector scope not found: ${selector}`); + } + + // djb2 hash — must match the algorithm in core.js computeContentHash/computeStructuralSignature + const simpleHash = (str: string): string => { + if (!str) return "0"; + let h = 5381; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16); + }; + + const interactiveRoles = new Set([ + "button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option", "slider", "spinbutton", + ]); + + const isVisible = (el: Element): boolean => { + const style = window.getComputedStyle(el as HTMLElement); + if (style.display === "none" || style.visibility === "hidden") return false; + const rect = (el as HTMLElement).getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const isEnabled = (el: Element): boolean => { + const htmlEl = el as HTMLElement; + const disabledAttr = htmlEl.getAttribute("disabled") !== null; + const ariaDisabled = (htmlEl.getAttribute("aria-disabled") || "").toLowerCase() === "true"; + return !disabledAttr && !ariaDisabled; + }; + + const inferRole = (el: Element): string => { + const explicit = (el.getAttribute("role") || "").trim(); + if (explicit) return explicit; + const tag = el.tagName.toLowerCase(); + if (tag === "a" && el.getAttribute("href")) return "link"; + if (tag === "button") return "button"; + if (tag === "select") return "combobox"; + if (tag === "textarea") return "textbox"; + if (tag === "input") { + const type = (el.getAttribute("type") || "text").toLowerCase(); + if (["button", "submit", "reset"].includes(type)) return "button"; + if (type === "checkbox") return "checkbox"; + if (type === "radio") return "radio"; + if (type === "search") return "searchbox"; + return "textbox"; + } + return ""; + }; + + const accessibleName = (el: Element): string => { + const ariaLabel = el.getAttribute("aria-label")?.trim(); + if (ariaLabel) return ariaLabel; + const labelledBy = el.getAttribute("aria-labelledby")?.trim(); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() || "") + .join(" ") + .trim(); + if (text) return text; + } + const htmlEl = el as HTMLElement; + const placeholder = htmlEl.getAttribute("placeholder")?.trim(); + if (placeholder) return placeholder; + const alt = htmlEl.getAttribute("alt")?.trim(); + if (alt) return alt; + const value = (htmlEl as HTMLInputElement).value?.trim(); + if (value) return value.slice(0, 80); + return (htmlEl.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80); + }; + + const isInteractiveEl = (el: Element): boolean => { + const tag = el.tagName.toLowerCase(); + const role = inferRole(el); + if (["button", "input", "select", "textarea", "summary", "option"].includes(tag)) return true; + if (tag === "a" && !!el.getAttribute("href")) return true; + if (interactiveRoles.has(role)) return true; + const tabindex = (el as HTMLElement).tabIndex; + if (tabindex >= 0) return true; + if ((el as HTMLElement).isContentEditable) return true; + return false; + }; + + const cssPath = (el: Element): string => { + const htmlEl = el as HTMLElement; + if (htmlEl.id) return `#${CSS.escape(htmlEl.id)}`; + const parts: string[] = []; + let current: Element | null = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) { + const tag = current.tagName.toLowerCase(); + let part = tag; + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === current!.tagName); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + part += `:nth-of-type(${idx})`; + } + } + parts.unshift(part); + current = current.parentElement; + } + return `body > ${parts.join(" > ")}`; + }; + + const domPath = (el: Element): number[] => { + const path: number[] = []; + let current: Element | null = el; + while (current && current !== document.documentElement) { + const parent = current.parentElement; + if (!parent) break; + const idx = Array.from(parent.children).indexOf(current); + path.unshift(idx); + current = parent; + } + return path; + }; + + const selectorHints = (el: Element): string[] => { + const hints: string[] = []; + const htmlEl = el as HTMLElement; + if (htmlEl.id) hints.push(`#${CSS.escape(htmlEl.id)}`); + const nameAttr = htmlEl.getAttribute("name"); + if (nameAttr) hints.push(`${el.tagName.toLowerCase()}[name="${CSS.escape(nameAttr)}"]`); + const aria = htmlEl.getAttribute("aria-label"); + if (aria) hints.push(`${el.tagName.toLowerCase()}[aria-label="${CSS.escape(aria)}"]`); + const placeholder = htmlEl.getAttribute("placeholder"); + if (placeholder) hints.push(`${el.tagName.toLowerCase()}[placeholder="${CSS.escape(placeholder)}"]`); + const cls = Array.from(el.classList).slice(0, 2); + if (cls.length > 0) hints.push(`${el.tagName.toLowerCase()}.${cls.map((c) => CSS.escape(c)).join(".")}`); + hints.push(cssPath(el)); + return Array.from(new Set(hints)).slice(0, 6); + }; + + // Mode-based element matching — used when a snapshot mode config is provided + const matchesMode = (el: Element, cfg: { tags: string[]; roles: string[]; selectors: string[]; ariaAttributes: string[] }): boolean => { + const tag = el.tagName.toLowerCase(); + if (cfg.tags.length > 0 && cfg.tags.includes(tag)) return true; + const role = inferRole(el); + if (cfg.roles.length > 0 && cfg.roles.includes(role)) return true; + for (const sel of cfg.selectors) { + try { if (el.matches(sel)) return true; } catch { /* invalid selector, skip */ } + } + for (const attr of cfg.ariaAttributes) { + if (el.hasAttribute(attr)) return true; + } + return false; + }; + + let elements = Array.from(root.querySelectorAll("*")); + + if (mc) { + // Mode takes precedence over interactiveOnly + if (mc.visibleOnly) { + // visible_only mode: include all elements that are visible + elements = elements.filter((el) => isVisible(el)); + } else if (mc.useInteractiveFilter) { + // interactive mode: reuse existing isInteractiveEl + elements = elements.filter((el) => isInteractiveEl(el)); + } else if (mc.containerExpand) { + // Container-expanding modes (dialog, errors): match containers, then include + // all interactive children of those containers, plus the containers themselves + const containers: Element[] = []; + const directMatches: Element[] = []; + for (const el of elements) { + if (matchesMode(el, mc)) { + // Check if this is a container element (has children) + const childEls = el.querySelectorAll("*"); + if (childEls.length > 0) { + containers.push(el); + } else { + directMatches.push(el); + } + } + } + // Collect container elements + all interactive children inside containers + const result = new Set(directMatches); + for (const container of containers) { + result.add(container); + const children = Array.from(container.querySelectorAll("*")); + for (const child of children) { + if (isInteractiveEl(child)) result.add(child); + } + } + elements = Array.from(result); + } else { + // Standard mode filtering by tag/role/selector/ariaAttribute + elements = elements.filter((el) => matchesMode(el, mc)); + } + } else if (!interactiveOnly) { + if (root instanceof Element) elements.unshift(root); + } else { + elements = elements.filter((el) => isInteractiveEl(el)); + } + + const seen = new Set(); + const unique = elements.filter((el) => { + if (seen.has(el)) return false; + seen.add(el); + return true; + }); + + // Fingerprint helpers — computed for each element in the snapshot + const computeNearestHeading = (el: Element): string => { + const headingTags = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]); + // Walk up ancestors looking for heading or preceding-sibling heading + let current: Element | null = el; + while (current && current !== document.body) { + // Check preceding siblings of current + let sib: Element | null = current.previousElementSibling; + while (sib) { + if (headingTags.has(sib.tagName) || sib.getAttribute("role") === "heading") { + return (sib.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80); + } + sib = sib.previousElementSibling; + } + // Check if the parent itself is a heading (unlikely but possible) + const parent = current.parentElement; + if (parent && (headingTags.has(parent.tagName) || parent.getAttribute("role") === "heading")) { + return (parent.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80); + } + current = parent; + } + return ""; + }; + + const computeFormOwnership = (el: Element): string => { + // Check form attribute (explicit form association) + const formAttr = el.getAttribute("form"); + if (formAttr) return formAttr; + // Walk up ancestors looking for
+ let current: Element | null = el.parentElement; + while (current && current !== document.body) { + if (current.tagName === "FORM") { + return (current as HTMLFormElement).id || (current as HTMLFormElement).name || "form"; + } + current = current.parentElement; + } + return ""; + }; + + return unique.slice(0, limit).map((el) => { + const tag = el.tagName.toLowerCase(); + const role = inferRole(el); + const textContent = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200); + const childTags = Array.from(el.children).map((c) => c.tagName.toLowerCase()); + + return { + tag, + role, + name: accessibleName(el), + selectorHints: selectorHints(el), + isVisible: isVisible(el), + isEnabled: isEnabled(el), + xpathOrPath: cssPath(el), + href: el.getAttribute("href") || undefined, + type: el.getAttribute("type") || undefined, + path: domPath(el), + contentHash: simpleHash(textContent), + structuralSignature: simpleHash(`${tag}|${role}|${childTags.join(",")}`), + nearestHeading: computeNearestHeading(el), + formOwnership: computeFormOwnership(el), + }; + }); + }, { ...options, modeConfig }); +} + +async function resolveRefTarget( + target: Page | Frame, + node: RefNode +): Promise<{ ok: true; selector: string } | { ok: false; reason: string }> { + return await target.evaluate((refNode) => { + const cssPath = (el: Element): string => { + const htmlEl = el as HTMLElement; + if (htmlEl.id) return `#${CSS.escape(htmlEl.id)}`; + const parts: string[] = []; + let current: Element | null = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) { + const tag = current.tagName.toLowerCase(); + let part = tag; + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === current!.tagName); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + part += `:nth-of-type(${idx})`; + } + } + parts.unshift(part); + current = current.parentElement; + } + return `body > ${parts.join(" > ")}`; + }; + + // djb2 hash — must match the algorithm in core.js and buildRefSnapshot + const simpleHash = (str: string): string => { + if (!str) return "0"; + let h = 5381; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16); + }; + + const byPath = (): Element | null => { + let current: Element | null = document.documentElement; + for (const idx of refNode.path || []) { + if (!current || idx < 0 || idx >= current.children.length) return null; + current = current.children[idx] as Element; + } + return current; + }; + + const nodeName = (el: Element): string => { + return ( + el.getAttribute("aria-label")?.trim() || + (el as HTMLInputElement).value?.trim() || + el.getAttribute("placeholder")?.trim() || + (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80) + ); + }; + + // Tier 1: path-based resolution + const pathEl = byPath(); + if (pathEl && pathEl.tagName.toLowerCase() === refNode.tag) { + return { ok: true as const, selector: cssPath(pathEl) }; + } + + // Tier 2: selector hints + for (const hint of refNode.selectorHints || []) { + try { + const el = document.querySelector(hint); + if (!el) continue; + if (el.tagName.toLowerCase() !== refNode.tag) continue; + return { ok: true as const, selector: cssPath(el) }; + } catch { + // ignore malformed selector hint + } + } + + // Tier 3: role + name match + const candidates = Array.from(document.querySelectorAll(refNode.tag)); + const target = candidates.find((el) => { + const role = el.getAttribute("role") || ""; + const name = nodeName(el); + const roleMatch = !refNode.role || role === refNode.role; + const nameMatch = !!refNode.name && name.toLowerCase() === refNode.name.toLowerCase(); + return roleMatch && nameMatch; + }); + if (target) { + return { ok: true as const, selector: cssPath(target) }; + } + + // Tier 4: structural signature + content hash fingerprint matching + if (refNode.contentHash && refNode.structuralSignature) { + const fpMatches: Element[] = []; + for (const candidate of candidates) { + const tag = candidate.tagName.toLowerCase(); + const role = candidate.getAttribute("role") || ""; + const textContent = (candidate.textContent || "").trim().replace(/\s+/g, " ").slice(0, 200); + const childTags = Array.from(candidate.children).map((c) => c.tagName.toLowerCase()); + const candidateContentHash = simpleHash(textContent); + const candidateStructSig = simpleHash(`${tag}|${role}|${childTags.join(",")}`); + if (candidateContentHash === refNode.contentHash && candidateStructSig === refNode.structuralSignature) { + fpMatches.push(candidate); + } + } + if (fpMatches.length === 1) { + return { ok: true as const, selector: cssPath(fpMatches[0]) }; + } + if (fpMatches.length > 1) { + return { ok: false as const, reason: "multiple fingerprint matches — ambiguous" }; + } + } + + return { ok: false as const, reason: "element not found in current DOM" }; + }, node); +} + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +export default function (pi: ExtensionAPI) { + // Notify on load + // Browser tools announce via tool errors if playwright is missing — no need for startup noise + + // Clean up on exit + pi.on("session_shutdown", async () => { + await closeBrowser(); + }); + + // ------------------------------------------------------------------------- + // browser_navigate + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_navigate", + label: "Browser Navigate", + description: + "Open the browser (if not already open) and navigate to a URL. Waits for network idle. Returns page title and current URL. Use ONLY for visually verifying locally-running web apps (e.g. http://localhost:3000). Do NOT use for documentation sites, GitHub, search results, or any external URL — use web_search instead.", + parameters: Type.Object({ + url: Type.String({ description: "URL to navigate to, e.g. http://localhost:3000" }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + beforeState = await captureCompactPageState(p, { includeBodyText: true }); + actionId = beginTrackedAction("browser_navigate", params, beforeState.url).id; + // Fast load + best-effort network settle (won't hang on WebSockets/polling) + await p.goto(params.url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await p.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 300)); + + const title = await p.title(); + const url = p.url(); + const viewport = p.viewportSize(); + const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown"; + const summary = await postActionSummary(p); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { includeBodyText: true }); + const diff = diffCompactStates(beforeState, afterState); + lastActionBeforeState = beforeState; + lastActionAfterState = afterState; + finishTrackedAction(actionId, { + status: "success", + afterUrl: afterState.url, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState, + afterState, + }); + + let screenshotContent: any[] = []; + try { + const buf = await p.screenshot({ type: "jpeg", quality: 80 }); + screenshotContent = [{ type: "image", data: buf.toString("base64"), mimeType: "image/jpeg" }]; + } catch {} + + return { + content: [ + { type: "text", text: `Navigated to: ${url}\nTitle: ${title}\nViewport: ${vpText}\nAction: ${actionId}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}` }, + ...screenshotContent, + ], + details: { title, url, status: "loaded", viewport: vpText, actionId, diff }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Navigation failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { status: "error", error: err.message, actionId }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_go_back + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_go_back", + label: "Browser Go Back", + description: "Navigate back in browser history. Returns a compact page summary after navigation.", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const response = await p.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }); + + if (!response) { + return { + content: [{ type: "text", text: "No previous page in history." }], + details: {}, + isError: true, + }; + } + + await p.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + + const title = await p.title(); + const url = p.url(); + const summary = await postActionSummary(p); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [{ type: "text", text: `Navigated back to: ${url}\nTitle: ${title}${jsErrors}\n\nPage summary:\n${summary}` }], + details: { title, url }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Go back failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_go_forward + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_go_forward", + label: "Browser Go Forward", + description: "Navigate forward in browser history. Returns a compact page summary after navigation.", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const response = await p.goForward({ waitUntil: "domcontentloaded", timeout: 10000 }); + + if (!response) { + return { + content: [{ type: "text", text: "No forward page in history." }], + details: {}, + isError: true, + }; + } + + await p.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + + const title = await p.title(); + const url = p.url(); + const summary = await postActionSummary(p); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [{ type: "text", text: `Navigated forward to: ${url}\nTitle: ${title}${jsErrors}\n\nPage summary:\n${summary}` }], + details: { title, url }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Go forward failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_reload + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_reload", + label: "Browser Reload", + description: "Reload the current page. Returns a screenshot, compact page summary, and page metadata (same shape as browser_navigate).", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + await p.reload({ waitUntil: "domcontentloaded", timeout: 30000 }); + await p.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + + const title = await p.title(); + const url = p.url(); + const viewport = p.viewportSize(); + const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown"; + const summary = await postActionSummary(p); + const jsErrors = getRecentErrors(p.url()); + + // Include screenshot like navigate does + let screenshotContent: any[] = []; + try { + const buf = await p.screenshot({ type: "jpeg", quality: 80 }); + screenshotContent = [{ + type: "image", + data: buf.toString("base64"), + mimeType: "image/jpeg", + }]; + } catch {} + + return { + content: [ + { + type: "text", + text: `Reloaded: ${url}\nTitle: ${title}\nViewport: ${vpText}${jsErrors}\n\nPage summary:\n${summary}`, + }, + ...screenshotContent, + ], + details: { title, url, viewport: vpText }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Reload failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_screenshot + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_screenshot", + label: "Browser Screenshot", + description: + "Take a screenshot of the current browser page and return it as an inline image. Uses JPEG for viewport/fullpage (smaller, configurable quality) and PNG for element crops (preserves transparency). Optionally crop to a specific element by CSS selector.", + parameters: Type.Object({ + fullPage: Type.Optional( + Type.Boolean({ description: "Capture the full scrollable page (default: false)" }) + ), + selector: Type.Optional( + Type.String({ + description: + "CSS selector of a specific element to screenshot (crops to that element's bounding box). If omitted, screenshots the entire viewport.", + }) + ), + quality: Type.Optional( + Type.Number({ + description: + "JPEG quality 1-100 (default: 80). Only applies to viewport/fullpage screenshots, not element crops. Lower = smaller image.", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + + let screenshotBuffer: Buffer; + let mimeType: string; + + if (params.selector) { + // Element screenshots: keep PNG (may have transparency) + const locator = p.locator(params.selector).first(); + screenshotBuffer = await locator.screenshot({ type: "png" }); + mimeType = "image/png"; + } else { + // Viewport/fullpage: use JPEG (3-5x smaller, fine for AI analysis) + const quality = params.quality ?? 80; + screenshotBuffer = await p.screenshot({ + fullPage: params.fullPage ?? false, + type: "jpeg", + quality, + }); + mimeType = "image/jpeg"; + } + + const base64Data = screenshotBuffer.toString("base64"); + const title = await p.title(); + const url = p.url(); + const viewport = p.viewportSize(); + const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown"; + const scope = params.selector ? `element "${params.selector}"` : params.fullPage ? "full page" : "viewport"; + + return { + content: [ + { + type: "text", + text: `Screenshot of ${scope}.\nPage: ${title}\nURL: ${url}\nViewport: ${vpText}`, + }, + { + type: "image", + data: base64Data, + mimeType, + }, + ], + details: { title, url, scope, viewport: vpText }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Screenshot failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_click + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_click", + label: "Browser Click", + description: + "Click an element on the page by CSS selector or by x,y coordinates. Returns a compact page summary plus lightweight verification details after clicking. Provide either selector or both x and y. Prefer selector over coordinates — selectors are more reliable because they handle shadow DOM via getByRole fallbacks. Use coordinates only when you have no other option.", + parameters: Type.Object({ + selector: Type.Optional( + Type.String({ description: "CSS selector of the element to click. The tool will try getByRole fallbacks if the CSS selector fails (handles shadow DOM)." }) + ), + x: Type.Optional(Type.Number({ description: "X coordinate to click" })), + y: Type.Optional(Type.Number({ description: "Y coordinate to click" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target }); + actionId = beginTrackedAction("browser_click", params, beforeState.url).id; + const beforeUrl = p.url(); + const beforeHash = getUrlHash(beforeUrl); + const beforeDialogCount = await countOpenDialogs(target); + const beforeTargetState = params.selector + ? await captureClickTargetState(target, params.selector) + : null; + + if (params.selector) { + // Try CSS selector first (5s). If it times out or the element is in + // shadow DOM (e.g. Google search), fall back to getByRole which + // pierces shadow DOM automatically. + try { + await target.locator(params.selector).first().click({ timeout: 5000 }); + } catch { + // Extract accessible name hint from the selector if present + const nameMatch = params.selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i); + const roleName = nameMatch?.[1]; + let clicked = false; + for (const role of ["combobox", "searchbox", "textbox", "button", "link"] as const) { + try { + const loc = roleName + ? target.getByRole(role, { name: new RegExp(roleName, "i") }) + : target.getByRole(role); + await loc.first().click({ timeout: 3000 }); + clicked = true; + break; + } catch { /* try next role */ } + } + if (!clicked) { + // Absolute last resort: coordinate click (mouse is page-level) + if (params.x !== undefined && params.y !== undefined) { + await p.mouse.click(params.x, params.y); + } else { + throw new Error(`Could not click selector "${params.selector}" — element not found (shadow DOM?)`); + } + } + } + } else if (params.x !== undefined && params.y !== undefined) { + await p.mouse.click(params.x, params.y); + } else { + return { + content: [ + { + type: "text", + text: "Must provide either selector or both x and y coordinates", + }, + ], + details: {}, + isError: true, + }; + } + + const settle = await settleAfterActionAdaptive(p); + + const url = p.url(); + const hash = getUrlHash(url); + const afterDialogCount = await countOpenDialogs(target); + const afterTargetState = params.selector + ? await captureClickTargetState(target, params.selector) + : null; + const targetStateChanged = !!beforeTargetState && !!afterTargetState && ( + beforeTargetState.exists !== afterTargetState.exists || + beforeTargetState.ariaExpanded !== afterTargetState.ariaExpanded || + beforeTargetState.ariaPressed !== afterTargetState.ariaPressed || + beforeTargetState.ariaSelected !== afterTargetState.ariaSelected || + beforeTargetState.open !== afterTargetState.open + ); + const verification = verificationFromChecks( + [ + { name: "url_changed", passed: url !== beforeUrl, value: url, expected: `!= ${beforeUrl}` }, + { name: "hash_changed", passed: hash !== beforeHash, value: hash, expected: `!= ${beforeHash}` }, + { name: "target_state_changed", passed: targetStateChanged, value: afterTargetState, expected: beforeTargetState }, + { name: "dialog_open", passed: afterDialogCount > beforeDialogCount, value: afterDialogCount, expected: `> ${beforeDialogCount}` }, + ], + "Try a more specific selector or click a clearly interactive element." + ); + const clickTarget = params.selector ?? `(${params.x}, ${params.y})`; + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: "success", + afterUrl: afterState.url, + verificationSummary: verification.verificationSummary, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState: beforeState!, + afterState, + }); + + return { + content: [{ type: "text", text: `Clicked: ${clickTarget}\nURL: ${url}\nAction: ${actionId}\n${verificationLine(verification)}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}` }], + details: { target: clickTarget, url, actionId, diff, ...settle, ...verification }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Click failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_drag + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_drag", + label: "Browser Drag", + description: + "Drag an element and drop it onto another element. Use for sortable lists, kanban boards, sliders, and any drag-and-drop UI.", + parameters: Type.Object({ + sourceSelector: Type.String({ + description: "CSS selector of the element to drag", + }), + targetSelector: Type.String({ + description: "CSS selector of the element to drop onto", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + await target.dragAndDrop(params.sourceSelector, params.targetSelector, { timeout: 10000 }); + const settle = await settleAfterActionAdaptive(p); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [{ + type: "text", + text: `Dragged "${params.sourceSelector}" → "${params.targetSelector}"${jsErrors}\n\nPage summary:\n${summary}`, + }], + details: { source: params.sourceSelector, target: params.targetSelector, ...settle }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Drag failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_type + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_type", + label: "Browser Type", + description: + "Type text into an input element. By default uses atomic fill (clears and sets value instantly). Use 'slowly' for character-by-character typing when you need to trigger key handlers (e.g. search autocomplete). Use 'submit' to press Enter after typing. Returns a compact page summary plus lightweight verification details. IMPORTANT: Always provide a selector — do NOT rely on coordinate clicks to focus an input before calling this. CSS attribute selectors like combobox[aria-label='X'] work for most inputs; for shadow DOM inputs (e.g. Google Search), the tool automatically tries getByRole fallbacks.", + parameters: Type.Object({ + text: Type.String({ description: "Text to type" }), + selector: Type.Optional( + Type.String({ description: "CSS selector of the input to type into (clicks it first). Examples: 'input[name=q]', 'textarea', 'combobox[aria-label=\"Search\"]'. The tool will try getByRole fallbacks if the CSS selector fails." }) + ), + clearFirst: Type.Optional( + Type.Boolean({ + description: + "Clear the input's existing value before typing (default: false). Use this when replacing existing text.", + }) + ), + submit: Type.Optional( + Type.Boolean({ + description: "Press Enter after typing to submit the form (default: false).", + }) + ), + slowly: Type.Optional( + Type.Boolean({ + description: + "Type one character at a time instead of filling atomically. Use when you need to trigger key handlers (e.g. search autocomplete). Default: false.", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target }); + actionId = beginTrackedAction("browser_type", params, beforeState.url).id; + const beforeUrl = p.url(); + + /** Helper: use getByRole fallbacks when CSS selector fails (shadow DOM etc.) */ + async function focusViaRole(selector: string): Promise { + const nameMatch = selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i); + const roleName = nameMatch?.[1]; + for (const role of ["combobox", "searchbox", "textbox"] as const) { + try { + const loc = roleName + ? target.getByRole(role, { name: new RegExp(roleName, "i") }) + : target.getByRole(role); + await loc.first().click({ timeout: 3000 }); + return true; + } catch { /* try next */ } + } + return false; + } + + if (params.selector) { + if (params.slowly) { + // Character-by-character with shadow DOM fallback + let focused = false; + try { + await target.locator(params.selector).first().click({ timeout: 5000 }); + focused = true; + } catch { + focused = await focusViaRole(params.selector); + } + if (!focused) throw new Error(`Could not focus selector "${params.selector}"`); + if (params.clearFirst) { + await p.keyboard.press("Control+A"); + await p.keyboard.press("Delete"); + } + await p.keyboard.type(params.text); + } else { + // 1. Try atomic fill (fast path — replaces value without triggering key events) + let filled = false; + try { + await target.locator(params.selector).first().fill(params.text, { timeout: 5000 }); + filled = true; + } catch { /* fall through */ } + + if (!filled) { + // 2. Try fill via getByRole (pierces shadow DOM) + const nameMatch = params.selector.match(/\[(?:aria-label|name|placeholder)="([^"]+)"\]/i); + const roleName = nameMatch?.[1]; + for (const role of ["combobox", "searchbox", "textbox"] as const) { + try { + const loc = roleName + ? target.getByRole(role, { name: new RegExp(roleName, "i") }) + : target.getByRole(role); + await loc.first().fill(params.text, { timeout: 3000 }); + filled = true; + break; + } catch { /* try next */ } + } + } + + if (!filled) { + // 3. Click to focus (with shadow DOM fallback) then pressSequentially + // pressSequentially is more reliable than keyboard.type for complex inputs + let focused = false; + try { + await target.locator(params.selector).first().click({ timeout: 5000 }); + focused = true; + } catch { + focused = await focusViaRole(params.selector); + } + if (!focused) throw new Error(`Could not focus selector "${params.selector}"`); + if (params.clearFirst) { + await p.keyboard.press("Control+A"); + await p.keyboard.press("Delete"); + } + await target.locator(":focus").pressSequentially(params.text, { timeout: 5000 }).catch(() => + p.keyboard.type(params.text) + ); + } else if (params.clearFirst) { + // fill() already replaced the value; clearFirst is a no-op here + } + } + } else { + // No selector — check something is actually focused before typing + const hasFocus = await target.evaluate(() => { + const el = document.activeElement; + return !!(el && el !== document.body && el !== document.documentElement); + }); + if (!hasFocus) { + return { + content: [{ type: "text", text: "Type failed: no element is focused. Use browser_click to focus an input first, or provide a selector." }], + details: { error: "no focused element" }, + isError: true, + }; + } + // Use pressSequentially via the focused element for reliability + await target.locator(":focus").pressSequentially(params.text, { timeout: 10000 }).catch(() => + p.keyboard.type(params.text) + ); + } + + if (params.submit) { + await p.keyboard.press("Enter"); + } + + const settle = await settleAfterActionAdaptive(p); + + const typedValue = await readInputLikeValue(target, params.selector); + const afterUrl = p.url(); + const verification = verificationFromChecks( + [ + { name: "value_equals_expected", passed: typedValue === params.text, value: typedValue, expected: params.text }, + { name: "value_contains_expected", passed: typeof typedValue === "string" && typedValue.includes(params.text), value: typedValue, expected: params.text }, + { name: "url_changed_after_submit", passed: !!params.submit && afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` }, + ], + "Try clearFirst=true, use a more specific selector, or set slowly=true for key-driven inputs." + ); + const typeTarget = params.selector ? ` into "${params.selector}"` : ""; + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { selectors: params.selector ? [params.selector] : [], includeBodyText: true, target }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: "success", + afterUrl: afterState.url, + verificationSummary: verification.verificationSummary, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState: beforeState!, + afterState, + }); + + return { + content: [{ type: "text", text: `Typed "${params.text}"${typeTarget}\nAction: ${actionId}\n${verificationLine(verification)}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}` }], + details: { text: params.text, selector: params.selector, typedValue, actionId, diff, ...settle, ...verification }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Type failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_upload_file + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_upload_file", + label: "Browser Upload File", + description: + "Set files on a file input element. The selector must target an element. Accepts one or more absolute file paths.", + parameters: Type.Object({ + selector: Type.String({ + description: 'CSS selector targeting the element', + }), + files: Type.Array(Type.String({ description: "Absolute path to a file" }), { + description: "One or more file paths to upload", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + // Strip leading @ (some models add it to paths) + const cleanFiles = params.files.map((f: string) => f.replace(/^@/, "")); + await target.locator(params.selector).first().setInputFiles(cleanFiles); + const settle = await settleAfterActionAdaptive(p); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [{ + type: "text", + text: `Uploaded ${cleanFiles.length} file(s) to "${params.selector}": ${cleanFiles.join(", ")}${jsErrors}\n\nPage summary:\n${summary}`, + }], + details: { selector: params.selector, files: cleanFiles, ...settle }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Upload failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_scroll + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_scroll", + label: "Browser Scroll", + description: "Scroll the page up or down by a given number of pixels. Returns scroll position (px and percentage) and an accessibility snapshot of the visible content.", + parameters: Type.Object({ + direction: StringEnum(["up", "down"] as const), + amount: Type.Optional( + Type.Number({ description: "Pixels to scroll (default: 300)" }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + const pixels = params.amount ?? 300; + const delta = params.direction === "up" ? -pixels : pixels; + await p.mouse.wheel(0, delta); + + const settle = await settleAfterActionAdaptive(p); + + const scrollInfo = await target.evaluate(() => ({ + scrollY: Math.round(window.scrollY), + scrollHeight: document.documentElement.scrollHeight, + clientHeight: document.documentElement.clientHeight, + })); + const maxScroll = scrollInfo.scrollHeight - scrollInfo.clientHeight; + const percent = maxScroll > 0 ? Math.round((scrollInfo.scrollY / maxScroll) * 100) : 0; + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [ + { + type: "text", + text: `Scrolled ${params.direction} by ${pixels}px\n` + + `Position: ${scrollInfo.scrollY}px / ${scrollInfo.scrollHeight}px (${percent}% down)\n` + + `Viewport height: ${scrollInfo.clientHeight}px${jsErrors}\n\nPage summary:\n${summary}`, + }, + ], + details: { direction: params.direction, amount: pixels, ...scrollInfo, percent, ...settle }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Scroll failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_console_logs + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_console_logs", + label: "Browser Console Logs", + description: + "Get all buffered browser console logs and JavaScript errors captured since the last clear. Each entry includes timestamp and page URL. Note: JS errors are also auto-surfaced in interaction tool responses — use this for the full log.", + parameters: Type.Object({ + clear: Type.Optional( + Type.Boolean({ + description: "Clear the buffer after returning logs (default: true)", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const shouldClear = params.clear !== false; + const logs = [...consoleLogs]; + + if (shouldClear) { + consoleLogs = []; + } + + if (logs.length === 0) { + return { + content: [{ type: "text", text: "No console logs captured." }], + details: { logs: [], count: 0 }, + }; + } + + const formatted = logs + .map((entry) => { + const time = new Date(entry.timestamp).toISOString().slice(11, 23); // HH:mm:ss.SSS + return `[${time}] [${entry.type.toUpperCase()}] ${entry.text}`; + }) + .join("\n"); + + const truncated = truncateText(formatted); + + return { + content: [ + { + type: "text", + text: `${logs.length} console log(s):\n\n${truncated}`, + }, + ], + details: { logs, count: logs.length }, + }; + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_network_logs + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_network_logs", + label: "Browser Network Logs", + description: + "Get buffered network requests and responses. Shows method, URL, status code, and resource type for all requests. Includes response body for failed requests (4xx/5xx). Use to debug API failures, CORS issues, missing resources, and auth problems.", + parameters: Type.Object({ + clear: Type.Optional( + Type.Boolean({ + description: "Clear the buffer after returning logs (default: true)", + }) + ), + filter: Type.Optional( + StringEnum(["all", "errors", "fetch-xhr"] as const) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const shouldClear = params.clear !== false; + let logs = [...networkLogs]; + + if (shouldClear) { + networkLogs = []; + } + + // Apply filter + if (params.filter === "errors") { + logs = logs.filter(e => e.failed || (e.status !== null && e.status >= 400)); + } else if (params.filter === "fetch-xhr") { + logs = logs.filter(e => e.resourceType === "fetch" || e.resourceType === "xhr"); + } + + if (logs.length === 0) { + return { + content: [{ type: "text", text: "No network requests captured." }], + details: { logs: [], count: 0 }, + }; + } + + const formatted = logs + .map((entry) => { + const time = new Date(entry.timestamp).toISOString().slice(11, 23); + const status = entry.failed + ? `FAILED (${entry.failureText})` + : `${entry.status}`; + let line = `[${time}] ${entry.method} ${entry.url} → ${status} (${entry.resourceType})`; + if (entry.responseBody) { + line += `\n Response: ${entry.responseBody}`; + } + return line; + }) + .join("\n"); + + const truncated = truncateText(formatted); + + return { + content: [ + { + type: "text", + text: `${logs.length} network request(s):\n\n${truncated}`, + }, + ], + details: { count: logs.length }, + }; + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_dialog_logs + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_dialog_logs", + label: "Browser Dialog Logs", + description: + "Get buffered JavaScript dialog events (alert, confirm, prompt, beforeunload). Dialogs are auto-accepted to prevent page freezes. Use this to see what dialogs appeared and their messages.", + parameters: Type.Object({ + clear: Type.Optional( + Type.Boolean({ + description: "Clear the buffer after returning logs (default: true)", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const shouldClear = params.clear !== false; + const logs = [...dialogLogs]; + + if (shouldClear) { + dialogLogs = []; + } + + if (logs.length === 0) { + return { + content: [{ type: "text", text: "No dialog events captured." }], + details: { logs: [], count: 0 }, + }; + } + + const formatted = logs + .map((entry) => { + const time = new Date(entry.timestamp).toISOString().slice(11, 23); + let line = `[${time}] ${entry.type}: "${entry.message}"`; + if (entry.defaultValue) { + line += ` (default: "${entry.defaultValue}")`; + } + line += ` → auto-accepted`; + return line; + }) + .join("\n"); + + const truncated = truncateText(formatted); + + return { + content: [ + { + type: "text", + text: `${logs.length} dialog(s):\n\n${truncated}`, + }, + ], + details: { logs, count: logs.length }, + }; + }, + }); + + // ------------------------------------------------------------------------- + // browser_evaluate + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_evaluate", + label: "Browser Evaluate", + description: + "Execute a JavaScript expression in the browser context and return the result. Useful for reading DOM state, checking values, etc.", + parameters: Type.Object({ + expression: Type.String({ + description: "JavaScript expression to evaluate in the page context", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const target = getActiveTarget(); + const result = await target.evaluate(params.expression); + + // Serialize result — handle undefined, null, circular refs, and non-JSON types + let serialized: string; + if (result === undefined) { + serialized = "undefined"; + } else { + try { + serialized = JSON.stringify(result, null, 2) ?? "undefined"; + } catch { + // Circular or non-serializable (e.g. window.open() returns a Window ref) + serialized = `[non-serializable: ${typeof result}]`; + } + } + + const truncated = truncateText(serialized); + return { + content: [{ type: "text", text: truncated }], + details: { expression: params.expression }, + }; + } catch (err: any) { + return { + content: [ + { + type: "text", + text: `Evaluation failed: ${err.message}`, + }, + ], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_close + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_close", + label: "Browser Close", + description: "Close the browser and clean up all resources.", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + await closeBrowser(); + return { + content: [{ type: "text", text: "Browser closed." }], + details: {}, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Close failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_trace_start + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_trace_start", + label: "Browser Trace Start", + description: "Start a Playwright trace for the current browser session and persist trace metadata under the session artifact directory.", + parameters: Type.Object({ + name: Type.Optional(Type.String({ description: "Optional short trace session name for artifact filenames." })), + title: Type.Optional(Type.String({ description: "Optional trace title recorded in metadata." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { context: browserContext } = await ensureBrowser(); + if (activeTraceSession) { + return { + content: [{ type: "text", text: `Trace already active: ${activeTraceSession.name}` }], + details: { error: "trace_already_active", activeTraceSession, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + const startedAt = Date.now(); + const name = (params.name?.trim() || `trace-${formatArtifactTimestamp(startedAt)}`).replace(/[^a-zA-Z0-9._-]+/g, "-"); + await browserContext.tracing.start({ screenshots: true, snapshots: true, sources: true, title: params.title ?? name }); + activeTraceSession = { startedAt, name, title: params.title ?? name }; + return { + content: [{ type: "text", text: `Trace started: ${name}\nSession dir: ${sessionArtifactDir}` }], + details: { activeTraceSession, ...getSessionArtifactMetadata() }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Trace start failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_trace_stop + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_trace_stop", + label: "Browser Trace Stop", + description: "Stop the active Playwright trace and write the trace zip to disk under the session artifact directory.", + parameters: Type.Object({ + name: Type.Optional(Type.String({ description: "Optional artifact basename override for the trace zip." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { context: browserContext } = await ensureBrowser(); + if (!activeTraceSession) { + return { + content: [{ type: "text", text: "No active trace session to stop." }], + details: { error: "trace_not_active", ...getSessionArtifactMetadata() }, + isError: true, + }; + } + const traceSession = activeTraceSession; + const traceName = (params.name?.trim() || traceSession.name).replace(/[^a-zA-Z0-9._-]+/g, "-"); + const tracePath = buildSessionArtifactPath(`${traceName}.trace.zip`); + await browserContext.tracing.stop({ path: tracePath }); + const fileStat = await stat(tracePath); + activeTraceSession = null; + return { + content: [{ type: "text", text: `Trace stopped: ${tracePath}` }], + details: { + path: tracePath, + bytes: fileStat.size, + elapsedMs: Date.now() - traceSession.startedAt, + traceName, + ...getSessionArtifactMetadata(), + }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Trace stop failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_export_har + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_export_har", + label: "Browser Export HAR", + description: "Export the truthfully recorded session HAR from disk to a stable artifact path and return compact metadata.", + parameters: Type.Object({ + filename: Type.Optional(Type.String({ description: "Optional destination filename within the session artifact directory." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + if (!harState.enabled || !harState.configuredAtContextCreation || !harState.path) { + return { + content: [{ type: "text", text: "HAR export unavailable: HAR recording was not enabled at browser context creation." }], + details: { error: "har_not_enabled", ...getSessionArtifactMetadata() }, + isError: true, + }; + } + const sourcePath = harState.path; + const destinationName = (params.filename?.trim() || `export-${HAR_FILENAME}`).replace(/[^a-zA-Z0-9._-]+/g, "-"); + const destinationPath = buildSessionArtifactPath(destinationName); + const exportResult = sourcePath === destinationPath + ? { path: sourcePath, bytes: (await stat(sourcePath)).size } + : await copyArtifactFile(sourcePath, destinationPath); + harState = { + ...harState, + exportCount: harState.exportCount + 1, + lastExportedPath: exportResult.path, + lastExportedAt: Date.now(), + }; + return { + content: [{ type: "text", text: `HAR exported: ${exportResult.path}` }], + details: { path: exportResult.path, bytes: exportResult.bytes, ...getSessionArtifactMetadata() }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `HAR export failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_timeline + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_timeline", + label: "Browser Timeline", + description: "Return a compact structured summary of the tracked browser action timeline and optional on-disk export path.", + parameters: Type.Object({ + writeToDisk: Type.Optional(Type.Boolean({ description: "Write the timeline JSON to disk under the session artifact directory." })), + filename: Type.Optional(Type.String({ description: "Optional JSON filename when writeToDisk is true." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const timeline = formatTimelineEntries(actionTimeline.entries, { + limit: actionTimeline.limit, + totalActions: actionTimeline.nextId - 1, + }); + let artifact: { path: string; bytes: number } | null = null; + if (params.writeToDisk) { + const filename = (params.filename?.trim() || "timeline.json").replace(/[^a-zA-Z0-9._-]+/g, "-"); + artifact = await writeArtifactFile(buildSessionArtifactPath(filename), JSON.stringify(timeline, null, 2)); + } + return { + content: [{ type: "text", text: artifact ? `${timeline.summary}\nArtifact: ${artifact.path}` : timeline.summary }], + details: { ...timeline, artifact, ...getSessionArtifactMetadata() }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Timeline failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_session_summary + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_session_summary", + label: "Browser Session Summary", + description: "Return a compact structured summary of the current browser session, including pages, actions, waits/assertions, bounded-history caveats, and trace/HAR state.", + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const pages = await getLivePagesSnapshot(); + const baseSummary = summarizeBrowserSession({ + timeline: actionTimeline, + totalActions: actionTimeline.nextId - 1, + pages, + activePageId: pageRegistry.activePageId, + activeFrame: getActiveFrameMetadata(), + consoleEntries: consoleLogs, + networkEntries: networkLogs, + dialogEntries: dialogLogs, + consoleLimit: 1000, + networkLimit: 1000, + dialogLimit: 1000, + sessionStartedAt, + now: Date.now(), + }); + const failureHypothesis = buildFailureHypothesis({ + timeline: actionTimeline, + consoleEntries: consoleLogs, + networkEntries: networkLogs, + dialogEntries: dialogLogs, + }); + const traceState = activeTraceSession + ? { status: "active", ...activeTraceSession } + : { status: "inactive", lastTracePath: sessionArtifactDir ? buildSessionArtifactPath("*.trace.zip") : null }; + const harSummary = { + enabled: harState.enabled, + configuredAtContextCreation: harState.configuredAtContextCreation, + path: harState.path, + exportCount: harState.exportCount, + lastExportedPath: harState.lastExportedPath, + lastExportedAt: harState.lastExportedAt, + }; + return { + content: [{ type: "text", text: `${baseSummary.summary}\nFailure hypothesis: ${failureHypothesis}` }], + details: { + ...baseSummary, + failureHypothesis, + trace: traceState, + har: harSummary, + ...getSessionArtifactMetadata(), + }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Session summary failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_debug_bundle + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_debug_bundle", + label: "Browser Debug Bundle", + description: "Write a timestamped debug bundle to disk with screenshot, logs, timeline, pages, session summary, and accessibility output, then return compact paths and counts.", + parameters: Type.Object({ + selector: Type.Optional(Type.String({ description: "Optional CSS selector to scope the accessibility snapshot before fallback behavior applies." })), + name: Type.Optional(Type.String({ description: "Optional short bundle name suffix for the output directory." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const startedAt = Date.now(); + const sessionDir = await ensureSessionArtifactDir(); + const bundleDir = path.join(ARTIFACT_ROOT, `${formatArtifactTimestamp(startedAt)}-${sanitizeArtifactName(params.name ?? "debug-bundle", "debug-bundle")}`); + await ensureDir(bundleDir); + const pages = await getLivePagesSnapshot(); + const timeline = formatTimelineEntries(actionTimeline.entries, { + limit: actionTimeline.limit, + totalActions: actionTimeline.nextId - 1, + }); + const sessionSummary = summarizeBrowserSession({ + timeline: actionTimeline, + totalActions: actionTimeline.nextId - 1, + pages, + activePageId: pageRegistry.activePageId, + activeFrame: getActiveFrameMetadata(), + consoleEntries: consoleLogs, + networkEntries: networkLogs, + dialogEntries: dialogLogs, + consoleLimit: 1000, + networkLimit: 1000, + dialogLimit: 1000, + sessionStartedAt, + now: Date.now(), + }); + const failureHypothesis = buildFailureHypothesis({ + timeline: actionTimeline, + consoleEntries: consoleLogs, + networkEntries: networkLogs, + dialogEntries: dialogLogs, + }); + const accessibility = await captureAccessibilityMarkdown(params.selector); + const screenshotPath = path.join(bundleDir, "screenshot.jpg"); + await p.screenshot({ path: screenshotPath, type: "jpeg", quality: 80, fullPage: false }); + const screenshotStat = await stat(screenshotPath); + const artifacts = { + screenshot: { path: screenshotPath, bytes: screenshotStat.size }, + console: await writeArtifactFile(path.join(bundleDir, "console.json"), JSON.stringify(consoleLogs, null, 2)), + network: await writeArtifactFile(path.join(bundleDir, "network.json"), JSON.stringify(networkLogs, null, 2)), + dialog: await writeArtifactFile(path.join(bundleDir, "dialog.json"), JSON.stringify(dialogLogs, null, 2)), + timeline: await writeArtifactFile(path.join(bundleDir, "timeline.json"), JSON.stringify(timeline, null, 2)), + summary: await writeArtifactFile(path.join(bundleDir, "summary.json"), JSON.stringify({ + ...sessionSummary, + failureHypothesis, + trace: activeTraceSession, + har: harState, + sessionArtifactDir: sessionDir, + }, null, 2)), + pages: await writeArtifactFile(path.join(bundleDir, "pages.json"), JSON.stringify(pages, null, 2)), + accessibility: await writeArtifactFile(path.join(bundleDir, "accessibility.md"), accessibility.snapshot), + }; + return { + content: [{ type: "text", text: `Debug bundle written: ${bundleDir}\n${sessionSummary.summary}\nFailure hypothesis: ${failureHypothesis}` }], + details: { + bundleDir, + artifacts, + accessibilityScope: accessibility.scope, + accessibilitySource: accessibility.source, + counts: { + console: consoleLogs.length, + network: networkLogs.length, + dialog: dialogLogs.length, + actions: timeline.count, + pages: pages.length, + }, + elapsedMs: Date.now() - startedAt, + summary: sessionSummary, + failureHypothesis, + ...getSessionArtifactMetadata(), + }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Debug bundle failed: ${err.message}` }], + details: { error: err.message, ...getSessionArtifactMetadata() }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_assert + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_assert", + label: "Browser Assert", + description: + "Run one or more explicit browser assertions and return structured PASS/FAIL results. Prefer this for verification instead of inferring success from prose summaries.", + promptGuidelines: [ + "Prefer browser_assert for browser verification instead of inferring success from summaries.", + "When finishing UI work, explicit browser assertions should usually be the final verification step.", + "Use checks for URL, text, selector state, value, and browser diagnostics whenever those signals are available.", + ], + parameters: Type.Object({ + checks: Type.Array( + Type.Object({ + kind: Type.String({ description: "Assertion kind, e.g. url_contains, text_visible, selector_visible, value_equals, no_console_errors, no_failed_requests, request_url_seen, response_status, console_message_matches, network_count, console_count, no_console_errors_since, no_failed_requests_since" }), + selector: Type.Optional(Type.String()), + text: Type.Optional(Type.String()), + value: Type.Optional(Type.String()), + checked: Type.Optional(Type.Boolean()), + sinceActionId: Type.Optional(Type.Number()), + }) + ), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + const state = await collectAssertionState(p, params.checks, target); + const result = evaluateAssertionChecks({ checks: params.checks, state }); + return { + content: [{ type: "text", text: `Browser assert\n\n${formatAssertionText(result)}` }], + details: { ...result, url: state.url, title: state.title }, + isError: !result.verified, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Browser assert failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_diff + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_diff", + label: "Browser Diff", + description: + "Report meaningful browser-state changes. By default compares the current page to the most recent tracked action state. Use this to understand what changed after a click, submit, or navigation.", + promptGuidelines: [ + "Use browser_diff after ambiguous or high-impact actions when you need to know what changed.", + "Prefer browser_diff over requesting a broad new page inspection when the question is change detection.", + ], + parameters: Type.Object({ + sinceActionId: Type.Optional(Type.Number({ description: "Optional action id to diff against. Uses that action's stored after-state when available." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + const current = await captureCompactPageState(p, { includeBodyText: true, target }); + let baseline: CompactPageState | null = null; + if (params.sinceActionId) { + const action = findAction(actionTimeline, params.sinceActionId) as { afterState?: CompactPageState } | null; + baseline = action?.afterState ?? null; + } + if (!baseline) { + baseline = lastActionAfterState ?? lastActionBeforeState; + } + if (!baseline) { + return { + content: [{ type: "text", text: "Browser diff unavailable: no prior tracked browser state exists yet." }], + details: { changed: false, changes: [], summary: "No prior tracked state" }, + isError: true, + }; + } + const diff = diffCompactStates(baseline, current); + return { + content: [{ type: "text", text: `Browser diff\n\n${formatDiffText(diff)}` }], + details: diff, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Browser diff failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_batch + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_batch", + label: "Browser Batch", + description: + "Execute multiple explicit browser steps in one call. Prefer this for obvious action sequences like click → type → wait → assert to reduce round trips and token usage.", + promptGuidelines: [ + "If the next 2-5 browser actions are obvious and low-risk, prefer browser_batch over multiple tiny browser calls.", + "Use browser_batch for explicit sequences like click → type → submit → wait → assert.", + "Keep browser_batch steps explicit; do not use it as a speculative planner.", + ], + parameters: Type.Object({ + steps: Type.Array( + Type.Object({ + action: StringEnum(["navigate", "click", "type", "key_press", "wait_for", "assert", "click_ref", "fill_ref"] as const), + selector: Type.Optional(Type.String()), + text: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + key: Type.Optional(Type.String()), + condition: Type.Optional(Type.String()), + value: Type.Optional(Type.String()), + threshold: Type.Optional(Type.String()), + timeout: Type.Optional(Type.Number()), + clearFirst: Type.Optional(Type.Boolean()), + submit: Type.Optional(Type.Boolean()), + ref: Type.Optional(Type.String()), + checks: Type.Optional(Type.Array(Type.Object({ + kind: Type.String({ description: "Assertion kind, e.g. url_contains, text_visible, selector_visible, value_equals, no_console_errors, no_failed_requests, request_url_seen, response_status, console_message_matches, network_count, console_count, no_console_errors_since, no_failed_requests_since" }), + selector: Type.Optional(Type.String()), + text: Type.Optional(Type.String()), + value: Type.Optional(Type.String()), + checked: Type.Optional(Type.Boolean()), + sinceActionId: Type.Optional(Type.Number()), + }))), + }) + ), + stopOnFailure: Type.Optional(Type.Boolean({ description: "Stop after the first failing step (default: true)." })), + finalSummaryOnly: Type.Optional(Type.Boolean({ description: "Return only the compact final batch summary in content while keeping step results in details." })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { includeBodyText: true, target }); + actionId = beginTrackedAction("browser_batch", params, beforeState.url).id; + const executeStep = async (step: any, index: number) => { + // Re-resolve target each step — frame selection may change during batch + const stepTarget = getActiveTarget(); + try { + switch (step.action) { + case "navigate": { + // Navigation is always page-level + await p.goto(step.url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await p.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}); + return { ok: true, action: step.action, url: p.url() }; + } + case "click": { + await stepTarget.locator(step.selector).first().click({ timeout: step.timeout ?? 8000 }); + await settleAfterActionAdaptive(p); + return { ok: true, action: step.action, selector: step.selector, url: p.url() }; + } + case "type": { + if (step.clearFirst) { + await stepTarget.locator(step.selector).first().fill(""); + } + await stepTarget.locator(step.selector).first().fill(step.text ?? "", { timeout: step.timeout ?? 8000 }); + if (step.submit) await p.keyboard.press("Enter"); + await settleAfterActionAdaptive(p); + return { ok: true, action: step.action, selector: step.selector, text: step.text }; + } + case "key_press": { + // Keyboard is page-level + await p.keyboard.press(step.key); + await settleAfterActionAdaptive(p, { checkFocusStability: true }); + return { ok: true, action: step.action, key: step.key }; + } + case "wait_for": { + const timeout = step.timeout ?? 10000; + // Validate params for all conditions + const waitValidation = validateWaitParams({ condition: step.condition, value: step.value, threshold: step.threshold }); + if (waitValidation) throw new Error(waitValidation.error); + + if (step.condition === "selector_visible") await stepTarget.waitForSelector(step.value, { state: "visible", timeout }); + else if (step.condition === "selector_hidden") await stepTarget.waitForSelector(step.value, { state: "hidden", timeout }); + else if (step.condition === "url_contains") await p.waitForURL((url) => url.toString().includes(step.value), { timeout }); + else if (step.condition === "network_idle") await p.waitForLoadState("networkidle", { timeout }); + else if (step.condition === "delay") await new Promise((resolve) => setTimeout(resolve, parseInt(step.value ?? "1000", 10))); + else if (step.condition === "text_visible") { + await stepTarget.waitForFunction( + (needle: string) => (document.body?.innerText ?? "").toLowerCase().includes(needle.toLowerCase()), + step.value!, + { timeout } + ); + } + else if (step.condition === "text_hidden") { + await stepTarget.waitForFunction( + (needle: string) => !(document.body?.innerText ?? "").toLowerCase().includes(needle.toLowerCase()), + step.value!, + { timeout } + ); + } + else if (step.condition === "request_completed") { + await getActivePage().waitForResponse( + (resp: any) => resp.url().includes(step.value!), + { timeout } + ); + } + else if (step.condition === "console_message") { + const needle = step.value!; + const startTime = Date.now(); + let found = false; + while (Date.now() - startTime < timeout) { + if (consoleLogs.find((entry) => includesNeedle(entry.text, needle))) { found = true; break; } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!found) throw new Error(`Timed out waiting for console message matching "${needle}" (${timeout}ms)`); + } + else if (step.condition === "element_count") { + const threshold = parseThreshold(step.threshold ?? ">=1"); + if (!threshold) throw new Error(`element_count threshold is malformed: "${step.threshold}"`); + const selector = step.value!; + const op = threshold.op; + const n = threshold.n; + await stepTarget.waitForFunction( + ({ selector, op, n }: { selector: string; op: string; n: number }) => { + const count = document.querySelectorAll(selector).length; + switch (op) { + case ">=": return count >= n; + case "<=": return count <= n; + case "==": return count === n; + case ">": return count > n; + case "<": return count < n; + default: return false; + } + }, + { selector, op, n }, + { timeout } + ); + } + else if (step.condition === "region_stable") { + const script = createRegionStableScript(step.value!); + await stepTarget.waitForFunction(script, undefined, { timeout, polling: 200 }); + } + else throw new Error(`Unsupported wait condition: ${step.condition}`); + return { ok: true, action: step.action, condition: step.condition, value: step.value }; + } + case "assert": { + const state = await collectAssertionState(p, step.checks ?? [], stepTarget); + const assertion = evaluateAssertionChecks({ checks: step.checks ?? [], state }); + return { ok: assertion.verified, action: step.action, summary: assertion.summary, assertion }; + } + case "click_ref": { + const parsedRef = parseRef(step.ref); + const node = currentRefMap[parsedRef.key]; + if (!node) throw new Error(`Unknown ref: ${step.ref}`); + const resolved = await resolveRefTarget(stepTarget, node); + if (!resolved.ok) throw new Error(resolved.reason); + await stepTarget.locator(resolved.selector).first().click({ timeout: step.timeout ?? 8000 }); + await settleAfterActionAdaptive(p); + return { ok: true, action: step.action, ref: step.ref }; + } + case "fill_ref": { + const parsedRef = parseRef(step.ref); + const node = currentRefMap[parsedRef.key]; + if (!node) throw new Error(`Unknown ref: ${step.ref}`); + const resolved = await resolveRefTarget(stepTarget, node); + if (!resolved.ok) throw new Error(resolved.reason); + if (step.clearFirst) await stepTarget.locator(resolved.selector).first().fill(""); + await stepTarget.locator(resolved.selector).first().fill(step.text ?? "", { timeout: step.timeout ?? 8000 }); + if (step.submit) await p.keyboard.press("Enter"); + await settleAfterActionAdaptive(p); + return { ok: true, action: step.action, ref: step.ref, text: step.text }; + } + default: + throw new Error(`Unsupported batch action: ${step.action}`); + } + } catch (err: any) { + return { ok: false, action: step.action, index, message: err.message }; + } + }; + const run = await runBatchSteps({ + steps: params.steps, + executeStep, + stopOnFailure: params.stopOnFailure !== false, + }); + // Re-resolve target at end of batch since steps may have changed frame selection + const batchEndTarget = getActiveTarget(); + const afterState = await captureCompactPageState(p, { includeBodyText: true, target: batchEndTarget }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: run.ok ? "success" : "error", + afterUrl: afterState.url, + diffSummary: diff.summary, + changed: diff.changed, + error: run.ok ? undefined : run.summary, + beforeState: beforeState!, + afterState, + }); + const summary = `${run.summary}\n${run.stepResults.map((step: any, index: number) => `- ${index + 1}. ${step.action}: ${step.ok ? "PASS" : "FAIL"}${step.message ? ` (${step.message})` : ""}`).join("\n")}`; + return { + content: [{ type: "text", text: params.finalSummaryOnly ? run.summary : `Browser batch\nAction: ${actionId}\n\n${summary}\n\nDiff:\n${formatDiffText(diff)}` }], + details: { actionId, diff, ...run }, + isError: !run.ok, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + return { + content: [{ type: "text", text: `Browser batch failed: ${err.message}` }], + details: { error: err.message, actionId }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_accessibility_tree + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_accessibility_tree", + label: "Browser Accessibility Tree", + description: + "Get the accessibility tree of the current page as structured text. Shows roles, names, labels, values, and states of all interactive elements. Use this to understand page structure before clicking — it reveals buttons, inputs, links, and their labels without needing to guess CSS selectors or coordinates. Much more reliable than inspecting the DOM directly.", + parameters: Type.Object({ + selector: Type.Optional( + Type.String({ + description: + "Scope the accessibility tree to a specific element by CSS selector (e.g. 'main', 'form', '#modal'). If omitted, returns the full page tree.", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + + // Use Playwright's aria snapshot which gives a structured YAML-like representation + let snapshot: string; + if (params.selector) { + const locator = target.locator(params.selector).first(); + snapshot = await locator.ariaSnapshot(); + } else { + snapshot = await target.locator("body").ariaSnapshot(); + } + + const truncated = truncateText(snapshot); + const scope = params.selector ? `element "${params.selector}"` : "full page"; + const viewport = p.viewportSize(); + const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown"; + + return { + content: [ + { + type: "text", + text: `Accessibility tree for ${scope} (viewport: ${vpText}):\n\n${truncated}`, + }, + ], + details: { scope, snapshot, viewport: vpText }, + }; + } catch (err: any) { + return { + content: [ + { + type: "text", + text: `Accessibility tree failed: ${err.message}`, + }, + ], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_find + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_find", + label: "Browser Find", + description: + "Find elements on the page by text content, ARIA role, or CSS selector. Returns only the matched nodes as a compact accessibility snapshot — far cheaper than browser_get_accessibility_tree. Use this after any action to locate a specific button, input, heading, or link before clicking it.", + promptGuidelines: [ + "Use browser_find for cheap targeted discovery before requesting the full accessibility tree.", + "Prefer browser_find when you need one button, input, heading, dialog, or alert rather than a full-page structure dump.", + ], + parameters: Type.Object({ + text: Type.Optional( + Type.String({ + description: "Find elements whose visible text contains this string (case-insensitive).", + }) + ), + role: Type.Optional( + Type.String({ + description: "ARIA role to filter by, e.g. 'button', 'link', 'heading', 'textbox', 'dialog', 'alert'.", + }) + ), + selector: Type.Optional( + Type.String({ + description: "CSS selector to scope the search. If omitted, searches the full page.", + }) + ), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (default: 20).", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const target = getActiveTarget(); + const limit = params.limit ?? 20; + + const results = await target.evaluate(({ text, role, selector, limit }) => { + const root = selector ? document.querySelector(selector) : document.body; + if (!root) return []; + + // Collect candidate elements + let candidates: Element[]; + if (role) { + // Query by ARIA role (native + explicit) + const roleMap: Record = { + button: 'button,[role="button"]', + link: 'a[href],[role="link"]', + heading: 'h1,h2,h3,h4,h5,h6,[role="heading"]', + textbox: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]),textarea,[role="textbox"]', + checkbox: 'input[type="checkbox"],[role="checkbox"]', + radio: 'input[type="radio"],[role="radio"]', + combobox: 'select,[role="combobox"]', + dialog: 'dialog,[role="dialog"]', + alert: '[role="alert"]', + navigation: 'nav,[role="navigation"]', + listitem: 'li,[role="listitem"]', + }; + const cssForRole = roleMap[role.toLowerCase()] ?? `[role="${role}"]`; + candidates = Array.from(root.querySelectorAll(cssForRole)); + } else { + candidates = Array.from(root.querySelectorAll('*')); + } + + // Filter by text if provided + if (text) { + const lower = text.toLowerCase(); + candidates = candidates.filter(el => + (el.textContent ?? "").toLowerCase().includes(lower) || + (el.getAttribute("aria-label") ?? "").toLowerCase().includes(lower) || + (el.getAttribute("placeholder") ?? "").toLowerCase().includes(lower) || + (el.getAttribute("value") ?? "").toLowerCase().includes(lower) + ); + } + + return candidates.slice(0, limit).map(el => { + const tag = el.tagName.toLowerCase(); + const id = el.id ? `#${el.id}` : ""; + const classes = Array.from(el.classList).slice(0, 2).map(c => `.${c}`).join(""); + const ariaLabel = el.getAttribute("aria-label") ?? ""; + const placeholder = el.getAttribute("placeholder") ?? ""; + const textContent = (el.textContent ?? "").trim().slice(0, 80); + const role = el.getAttribute("role") ?? ""; + const type = el.getAttribute("type") ?? ""; + const href = el.getAttribute("href") ?? ""; + const value = (el as HTMLInputElement).value ?? ""; + + return { tag, id, classes, ariaLabel, placeholder, textContent, role, type, href, value }; + }); + }, { text: params.text, role: params.role, selector: params.selector, limit }); + + if (results.length === 0) { + return { + content: [{ type: "text", text: "No elements found matching the criteria." }], + details: { count: 0 }, + }; + } + + const lines = results.map((r: any) => { + const parts: string[] = [`${r.tag}${r.id}${r.classes}`]; + if (r.role) parts.push(`role="${r.role}"`); + if (r.type) parts.push(`type="${r.type}"`); + if (r.ariaLabel) parts.push(`aria-label="${r.ariaLabel}"`); + if (r.placeholder) parts.push(`placeholder="${r.placeholder}"`); + if (r.href) parts.push(`href="${r.href.slice(0, 60)}"`); + if (r.value) parts.push(`value="${r.value.slice(0, 40)}"`); + if (r.textContent && !r.ariaLabel) parts.push(`"${r.textContent}"`); + return " " + parts.join(" "); + }); + + const criteria: string[] = []; + if (params.role) criteria.push(`role="${params.role}"`); + if (params.text) criteria.push(`text="${params.text}"`); + if (params.selector) criteria.push(`within="${params.selector}"`); + + return { + content: [ + { + type: "text", + text: `Found ${results.length} element(s) [${criteria.join(", ")}]:\n${lines.join("\n")}`, + }, + ], + details: { count: results.length, results }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Find failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_snapshot_refs + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_snapshot_refs", + label: "Browser Snapshot Refs", + description: + "Capture a compact inventory of interactive elements and assign deterministic versioned refs (@vN:e1, @vN:e2, ...). Use these refs with browser_click_ref, browser_fill_ref, and browser_hover_ref.", + parameters: Type.Object({ + selector: Type.Optional( + Type.String({ + description: "Optional CSS selector scope for the snapshot (e.g. 'main', 'form', '#modal').", + }) + ), + interactiveOnly: Type.Optional( + Type.Boolean({ + description: "Include only interactive elements (default: true).", + }) + ), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of elements to include (default: 40).", + }) + ), + mode: Type.Optional( + Type.String({ + description: "Semantic snapshot mode that pre-filters elements by category. When set, overrides interactiveOnly. Modes: interactive, form, dialog, navigation, errors, headings, visible_only.", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + + // Validate mode if provided + const mode = params.mode; + if (mode !== undefined) { + const modeConfig = getSnapshotModeConfig(mode); + if (!modeConfig) { + const validModes = Object.keys(SNAPSHOT_MODES).join(", "); + return { + content: [{ type: "text", text: `Unknown snapshot mode: "${mode}". Valid modes: ${validModes}` }], + details: { error: `Unknown mode: ${mode}`, validModes: Object.keys(SNAPSHOT_MODES) }, + isError: true, + }; + } + } + + const interactiveOnly = params.interactiveOnly !== false; + const limit = Math.max(1, Math.min(200, Math.floor(params.limit ?? 40))); + const rawNodes = await buildRefSnapshot(target, { + selector: params.selector, + interactiveOnly, + limit, + mode, + }); + + refVersion += 1; + const nextMap: Record = {}; + for (let i = 0; i < rawNodes.length; i += 1) { + const ref = `e${i + 1}`; + nextMap[ref] = { ref, ...rawNodes[i] }; + } + currentRefMap = nextMap; + // Record frame context when snapshot taken inside a frame + const frameCtx = activeFrame ? (activeFrame.name() || activeFrame.url()) : undefined; + refMetadata = { + url: p.url(), + timestamp: Date.now(), + selectorScope: params.selector, + interactiveOnly, + limit, + version: refVersion, + frameContext: frameCtx, + mode, + }; + + if (rawNodes.length === 0) { + return { + content: [{ + type: "text", + text: "No elements found for ref snapshot (try interactiveOnly=false or a wider selector scope).", + }], + details: { + count: 0, + version: refVersion, + metadata: refMetadata, + refs: {}, + }, + }; + } + + const versionedRefs: Record = {}; + const lines = Object.values(nextMap).map((node) => { + const versionedRef = formatVersionedRef(refVersion, node.ref); + versionedRefs[versionedRef] = node; + const parts: string[] = [versionedRef, node.role || node.tag]; + if (node.name) parts.push(`"${node.name}"`); + if (node.href) parts.push(`href="${node.href.slice(0, 80)}"`); + if (!node.isVisible) parts.push("(hidden)"); + if (!node.isEnabled) parts.push("(disabled)"); + return parts.join(" "); + }); + + const modeLabel = mode ? `Mode: ${mode}\n` : ""; + return { + content: [{ + type: "text", + text: + `Ref snapshot v${refVersion} (${rawNodes.length} element(s))\n` + + `URL: ${p.url()}\n` + + `Scope: ${params.selector ?? "body"}\n` + + modeLabel + + `Use versioned refs exactly as shown (e.g. @v${refVersion}:e1).\n\n` + + lines.join("\n"), + }], + details: { + count: rawNodes.length, + version: refVersion, + metadata: refMetadata, + refs: nextMap, + versionedRefs, + }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Snapshot refs failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_ref + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_ref", + label: "Browser Get Ref", + description: "Inspect stored metadata for one deterministic element ref (prefer versioned format, e.g. @v3:e1).", + parameters: Type.Object({ + ref: Type.String({ description: "Reference id, preferably versioned (e.g. '@v3:e1')." }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const parsedRef = parseRef(params.ref); + if (parsedRef.version !== null && refMetadata && parsedRef.version !== refMetadata.version) { + return { + content: [{ type: "text", text: staleRefGuidance(parsedRef.display, `snapshot version mismatch (have v${refMetadata.version})`) }], + details: { error: "ref_stale", ref: parsedRef.display, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version }, + isError: true, + }; + } + + const node = currentRefMap[parsedRef.key]; + if (!node) { + return { + content: [{ type: "text", text: staleRefGuidance(parsedRef.display, "ref not found") }], + details: { error: "ref_not_found", ref: parsedRef.display, metadata: refMetadata }, + isError: true, + }; + } + + const versionedRef = formatVersionedRef(refMetadata?.version ?? refVersion, node.ref); + return { + content: [{ + type: "text", + text: `${versionedRef}: ${node.role || node.tag}${node.name ? ` "${node.name}"` : ""}\nVisible: ${node.isVisible}\nEnabled: ${node.isEnabled}\nPath: ${node.xpathOrPath}`, + }], + details: { ref: versionedRef, node, metadata: refMetadata }, + }; + }, + }); + + // ------------------------------------------------------------------------- + // browser_click_ref + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_click_ref", + label: "Browser Click Ref", + description: "Click a previously snapshotted element by deterministic versioned ref (e.g. @v3:e2).", + parameters: Type.Object({ + ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e2'." }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const parsedRef = parseRef(params.ref); + const requestedRef = parsedRef.display; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + if (parsedRef.version === null) { + return { + content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }], + details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata && parsedRef.version !== refMetadata.version) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }], + details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version }, + isError: true, + }; + } + const ref = parsedRef.key; + const node = currentRefMap[ref]; + if (!node) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "ref not found") }], + details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata?.url && refMetadata.url !== p.url()) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "URL changed since snapshot") }], + details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() }, + isError: true, + }; + } + + const resolved = await resolveRefTarget(target, node); + if (!resolved.ok) { + const reason = (resolved as { ok: false; reason: string }).reason; + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, reason) }], + details: { error: "ref_stale", ref: requestedRef, reason }, + isError: true, + }; + } + + const beforeUrl = p.url(); + const beforeHash = getUrlHash(beforeUrl); + const beforeDialogCount = await countOpenDialogs(target); + const beforeTargetState = await captureClickTargetState(target, resolved.selector); + await target.locator(resolved.selector).first().click({ timeout: 8000 }); + const settle = await settleAfterActionAdaptive(p); + + const afterUrl = p.url(); + const afterHash = getUrlHash(afterUrl); + const afterDialogCount = await countOpenDialogs(target); + const afterTargetState = await captureClickTargetState(target, resolved.selector); + const targetStateChanged = + beforeTargetState.exists !== afterTargetState.exists || + beforeTargetState.ariaExpanded !== afterTargetState.ariaExpanded || + beforeTargetState.ariaPressed !== afterTargetState.ariaPressed || + beforeTargetState.ariaSelected !== afterTargetState.ariaSelected || + beforeTargetState.open !== afterTargetState.open; + const verification = verificationFromChecks( + [ + { name: "url_changed", passed: afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` }, + { name: "hash_changed", passed: afterHash !== beforeHash, value: afterHash, expected: `!= ${beforeHash}` }, + { name: "target_state_changed", passed: targetStateChanged, value: afterTargetState, expected: beforeTargetState }, + { name: "dialog_open", passed: afterDialogCount > beforeDialogCount, value: afterDialogCount, expected: `> ${beforeDialogCount}` }, + ], + "Ref may now point to an inert element. Refresh refs with browser_snapshot_refs and retry." + ); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const versionedRef = formatVersionedRef(refMetadata?.version ?? refVersion, node.ref); + return { + content: [{ + type: "text", + text: `Clicked ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""})\n${verificationLine(verification)}${jsErrors}\n\nPage summary:\n${summary}`, + }], + details: { ref: versionedRef, selector: resolved.selector, url: p.url(), ...settle, ...verification }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const reason = firstErrorLine(err); + const content: any[] = [ + { type: "text", text: staleRefGuidance(requestedRef, `action failed: ${reason}`) }, + { type: "text", text: `Click ref failed: ${err.message}` }, + ]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_hover_ref + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_hover_ref", + label: "Browser Hover Ref", + description: "Hover a previously snapshotted element by deterministic versioned ref (e.g. @v3:e4).", + parameters: Type.Object({ + ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e4'." }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const parsedRef = parseRef(params.ref); + const requestedRef = parsedRef.display; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + if (parsedRef.version === null) { + return { + content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }], + details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata && parsedRef.version !== refMetadata.version) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }], + details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version }, + isError: true, + }; + } + const ref = parsedRef.key; + const node = currentRefMap[ref]; + if (!node) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "ref not found") }], + details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata?.url && refMetadata.url !== p.url()) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "URL changed since snapshot") }], + details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() }, + isError: true, + }; + } + + const resolved = await resolveRefTarget(target, node); + if (!resolved.ok) { + const reason = (resolved as { ok: false; reason: string }).reason; + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, reason) }], + details: { error: "ref_stale", ref: requestedRef, reason }, + isError: true, + }; + } + + await target.locator(resolved.selector).first().hover({ timeout: 8000 }); + const settle = await settleAfterActionAdaptive(p); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const versionedRef = formatVersionedRef(refMetadata?.version ?? refVersion, node.ref); + return { + content: [{ + type: "text", + text: `Hovered ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""})${jsErrors}\n\nPage summary:\n${summary}`, + }], + details: { ref: versionedRef, selector: resolved.selector, url: p.url(), ...settle }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const reason = firstErrorLine(err); + const content: any[] = [ + { type: "text", text: staleRefGuidance(requestedRef, `action failed: ${reason}`) }, + { type: "text", text: `Hover ref failed: ${err.message}` }, + ]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_fill_ref + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_fill_ref", + label: "Browser Fill Ref", + description: "Fill/type text into an input-like element by deterministic versioned ref (e.g. @v3:e1).", + parameters: Type.Object({ + ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e1'." }), + text: Type.String({ description: "Text to enter." }), + clearFirst: Type.Optional( + Type.Boolean({ description: "Clear existing value first (default: false)." }) + ), + submit: Type.Optional( + Type.Boolean({ description: "Press Enter after typing (default: false)." }) + ), + slowly: Type.Optional( + Type.Boolean({ description: "Type character-by-character (default: false)." }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const parsedRef = parseRef(params.ref); + const requestedRef = parsedRef.display; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + if (parsedRef.version === null) { + return { + content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }], + details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata && parsedRef.version !== refMetadata.version) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }], + details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version }, + isError: true, + }; + } + const ref = parsedRef.key; + const node = currentRefMap[ref]; + if (!node) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "ref not found") }], + details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata }, + isError: true, + }; + } + if (refMetadata?.url && refMetadata.url !== p.url()) { + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, "URL changed since snapshot") }], + details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() }, + isError: true, + }; + } + + const resolved = await resolveRefTarget(target, node); + if (!resolved.ok) { + const reason = (resolved as { ok: false; reason: string }).reason; + return { + content: [{ type: "text", text: staleRefGuidance(requestedRef, reason) }], + details: { error: "ref_stale", ref: requestedRef, reason }, + isError: true, + }; + } + + const locator = target.locator(resolved.selector).first(); + const beforeUrl = p.url(); + if (params.slowly) { + await locator.click({ timeout: 8000 }); + if (params.clearFirst) { + await p.keyboard.press("Control+A"); + await p.keyboard.press("Delete"); + } + await p.keyboard.type(params.text); + } else { + if (params.clearFirst) { + await locator.fill(""); + } + await locator.fill(params.text, { timeout: 8000 }); + } + if (params.submit) { + await p.keyboard.press("Enter"); + } + const settle = await settleAfterActionAdaptive(p); + + const filledValue = await readInputLikeValue(target, resolved.selector); + const afterUrl = p.url(); + const verification = verificationFromChecks( + [ + { name: "value_equals_expected", passed: filledValue === params.text, value: filledValue, expected: params.text }, + { name: "value_contains_expected", passed: typeof filledValue === "string" && filledValue.includes(params.text), value: filledValue, expected: params.text }, + { name: "url_changed_after_submit", passed: !!params.submit && afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` }, + ], + "Try refreshing refs and confirm this ref still targets an input-like element." + ); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const versionedRef = formatVersionedRef(refMetadata?.version ?? refVersion, node.ref); + return { + content: [{ + type: "text", + text: `Filled ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""}) with "${params.text}"\n${verificationLine(verification)}${jsErrors}\n\nPage summary:\n${summary}`, + }], + details: { ref: versionedRef, selector: resolved.selector, url: p.url(), filledValue, ...settle, ...verification }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const reason = firstErrorLine(err); + const content: any[] = [ + { type: "text", text: staleRefGuidance(requestedRef, `action failed: ${reason}`) }, + { type: "text", text: `Fill ref failed: ${err.message}` }, + ]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_wait_for + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_wait_for", + label: "Browser Wait For", + description: + "Wait for a condition before continuing. Use after actions that trigger async updates — data fetches, route changes, animations, loading spinners. Choose the appropriate condition: 'selector_visible' waits for an element to appear, 'selector_hidden' waits for it to disappear, 'url_contains' waits for the URL to match, 'network_idle' waits for all network requests to finish, 'delay' waits a fixed number of milliseconds, 'text_visible' waits for text to appear in the page body, 'text_hidden' waits for text to disappear from the page body, 'request_completed' waits for a network response whose URL contains the given substring, 'console_message' waits for a console log message containing the given substring, 'element_count' waits for the number of elements matching the CSS selector in 'value' to satisfy the 'threshold' expression (e.g. '>=3', '==0', '<5'), 'region_stable' waits for the DOM region matching the CSS selector in 'value' to stop changing.", + parameters: Type.Object({ + condition: StringEnum([ + "selector_visible", + "selector_hidden", + "url_contains", + "network_idle", + "delay", + "text_visible", + "text_hidden", + "request_completed", + "console_message", + "element_count", + "region_stable", + ] as const), + value: Type.Optional( + Type.String({ + description: + "For selector_visible/selector_hidden/element_count/region_stable: CSS selector. For url_contains/request_completed: URL substring. For text_visible/text_hidden/console_message: text substring. For delay: milliseconds as a string (e.g. '1000'). Not used for network_idle.", + }) + ), + threshold: Type.Optional( + Type.String({ + description: + "Threshold expression for element_count (e.g. '>=3', '==0', '<5', or bare '3' which defaults to >=). Only used with element_count condition.", + }) + ), + timeout: Type.Optional( + Type.Number({ + description: "Maximum milliseconds to wait before failing (default: 10000)", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + const timeout = params.timeout ?? 10000; + + // Validate params for all conditions using shared validator + const validation = validateWaitParams({ condition: params.condition, value: params.value, threshold: (params as any).threshold }); + if (validation) { + return { + content: [{ type: "text", text: validation.error }], + details: { error: validation.error, condition: params.condition }, + isError: true, + }; + } + + switch (params.condition) { + case "selector_visible": { + if (!params.value) { + return { + content: [{ type: "text", text: "selector_visible requires a value (CSS selector)" }], + details: {}, + isError: true, + }; + } + await target.waitForSelector(params.value, { state: "visible", timeout }); + return { + content: [{ type: "text", text: `Element "${params.value}" is now visible` }], + details: { condition: params.condition, value: params.value }, + }; + } + + case "selector_hidden": { + if (!params.value) { + return { + content: [{ type: "text", text: "selector_hidden requires a value (CSS selector)" }], + details: {}, + isError: true, + }; + } + await target.waitForSelector(params.value, { state: "hidden", timeout }); + return { + content: [{ type: "text", text: `Element "${params.value}" is now hidden` }], + details: { condition: params.condition, value: params.value }, + }; + } + + case "url_contains": { + if (!params.value) { + return { + content: [{ type: "text", text: "url_contains requires a value (URL substring)" }], + details: {}, + isError: true, + }; + } + await p.waitForURL((url) => url.toString().includes(params.value!), { timeout }); + return { + content: [{ type: "text", text: `URL now contains "${params.value}". Current URL: ${p.url()}` }], + details: { condition: params.condition, value: params.value, url: p.url() }, + }; + } + + case "network_idle": { + await p.waitForLoadState("networkidle", { timeout }); + return { + content: [{ type: "text", text: "Network is idle" }], + details: { condition: params.condition }, + }; + } + + case "delay": { + const ms = parseInt(params.value ?? "1000", 10); + if (isNaN(ms)) { + return { + content: [{ type: "text", text: "delay requires a numeric value (milliseconds)" }], + details: {}, + isError: true, + }; + } + await new Promise((resolve) => setTimeout(resolve, ms)); + return { + content: [{ type: "text", text: `Waited ${ms}ms` }], + details: { condition: params.condition, ms }, + }; + } + + case "text_visible": { + await target.waitForFunction( + (needle: string) => { + const body = document.body?.innerText ?? ""; + return body.toLowerCase().includes(needle.toLowerCase()); + }, + params.value!, + { timeout } + ); + return { + content: [{ type: "text", text: `Text "${params.value}" is now visible on the page` }], + details: { condition: params.condition, value: params.value }, + }; + } + + case "text_hidden": { + await target.waitForFunction( + (needle: string) => { + const body = document.body?.innerText ?? ""; + return !body.toLowerCase().includes(needle.toLowerCase()); + }, + params.value!, + { timeout } + ); + return { + content: [{ type: "text", text: `Text "${params.value}" is no longer visible on the page` }], + details: { condition: params.condition, value: params.value }, + }; + } + + case "request_completed": { + // waitForResponse is Page-only (not available on Frame) + const response = await getActivePage().waitForResponse( + (resp) => resp.url().includes(params.value!), + { timeout } + ); + return { + content: [{ type: "text", text: `Request completed: ${response.url()} (status ${response.status()})` }], + details: { condition: params.condition, value: params.value, url: response.url(), status: response.status() }, + }; + } + + case "console_message": { + // Poll consoleLogs array — no Playwright built-in for this + const needle = params.value!; + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const match = consoleLogs.find((entry) => includesNeedle(entry.text, needle)); + if (match) { + return { + content: [{ type: "text", text: `Console message matching "${needle}" found: "${match.text}"` }], + details: { condition: params.condition, value: needle, matchedText: match.text, matchedType: match.type }, + }; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for console message matching "${needle}" (${timeout}ms)`); + } + + case "element_count": { + const threshold = parseThreshold((params as any).threshold ?? ">=1"); + if (!threshold) { + return { + content: [{ type: "text", text: `element_count threshold is malformed: "${(params as any).threshold}"` }], + details: { error: "malformed threshold", condition: params.condition }, + isError: true, + }; + } + const selector = params.value!; + const op = threshold.op; + const n = threshold.n; + await target.waitForFunction( + ({ selector, op, n }: { selector: string; op: string; n: number }) => { + const count = document.querySelectorAll(selector).length; + switch (op) { + case ">=": return count >= n; + case "<=": return count <= n; + case "==": return count === n; + case ">": return count > n; + case "<": return count < n; + default: return false; + } + }, + { selector, op, n }, + { timeout } + ); + return { + content: [{ type: "text", text: `Element count for "${selector}" satisfies ${op}${n}` }], + details: { condition: params.condition, value: selector, threshold: `${op}${n}` }, + }; + } + + case "region_stable": { + const script = createRegionStableScript(params.value!); + await target.waitForFunction(script, undefined, { timeout, polling: 200 }); + return { + content: [{ type: "text", text: `Region "${params.value}" is now stable` }], + details: { condition: params.condition, value: params.value }, + }; + } + } + } catch (err: any) { + return { + content: [{ type: "text", text: `Wait failed: ${err.message}` }], + details: { error: err.message, condition: params.condition, value: params.value }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_hover + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_hover", + label: "Browser Hover", + description: + "Move the mouse over an element to trigger hover states — reveals tooltips, dropdown menus, CSS :hover effects, and other hover-dependent UI. Returns a compact page summary showing the resulting hover state.", + parameters: Type.Object({ + selector: Type.String({ + description: "CSS selector of the element to hover over", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + await target.locator(params.selector).first().hover({ timeout: 10000 }); + const settle = await settleAfterActionAdaptive(p); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + + return { + content: [{ type: "text", text: `Hovering over "${params.selector}"${jsErrors}\n\nPage summary:\n${summary}` }], + details: { selector: params.selector, ...settle }, + }; + } catch (err: any) { + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Hover failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_key_press + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_key_press", + label: "Browser Key Press", + description: + "Press a keyboard key or key combination. Returns a compact page summary plus lightweight verification details after the key press. Use for: submitting forms (Enter), closing modals (Escape), navigating focusable elements (Tab / Shift+Tab), operating dropdowns and menus (ArrowDown, ArrowUp, Space), copying/pasting (Meta+C, Meta+V). Key names follow the DOM KeyboardEvent key convention.", + parameters: Type.Object({ + key: Type.String({ + description: + "Key or combination to press, e.g. 'Enter', 'Escape', 'Tab', 'ArrowDown', 'ArrowUp', 'Space', 'Meta+A', 'Shift+Tab', 'Control+Enter'", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { includeBodyText: true, target }); + actionId = beginTrackedAction("browser_key_press", params, beforeState.url).id; + const beforeUrl = p.url(); + const beforeFocus = await readFocusedDescriptor(target); + const beforeDialogCount = await countOpenDialogs(target); + + await p.keyboard.press(params.key); + const settle = await settleAfterActionAdaptive(p, { checkFocusStability: true }); + + const afterUrl = p.url(); + const afterFocus = await readFocusedDescriptor(target); + const afterDialogCount = await countOpenDialogs(target); + const verification = verificationFromChecks( + [ + { name: "url_changed", passed: afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` }, + { name: "focus_changed", passed: afterFocus !== beforeFocus, value: afterFocus, expected: `!= ${beforeFocus}` }, + { name: "dialog_open", passed: afterDialogCount > beforeDialogCount, value: afterDialogCount, expected: `> ${beforeDialogCount}` }, + ], + "If this key should trigger UI changes, confirm focus is on the intended element first." + ); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { includeBodyText: true, target }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: "success", + afterUrl: afterState.url, + verificationSummary: verification.verificationSummary, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState: beforeState!, + afterState, + }); + + return { + content: [{ type: "text", text: `Pressed "${params.key}"\nAction: ${actionId}\n${verificationLine(verification)}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}` }], + details: { key: params.key, beforeFocus, afterFocus, actionId, diff, ...settle, ...verification }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Key press failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_select_option + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_select_option", + label: "Browser Select Option", + description: + "Select an option from a element", + }), + option: Type.String({ + description: + "The option to select — can be the visible label text or the value attribute. Will try label first, then value.", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target }); + actionId = beginTrackedAction("browser_select_option", params, beforeState.url).id; + + let selected: string[]; + try { + selected = await target.selectOption(params.selector, { label: params.option }, { timeout: 5000 }); + } catch { + selected = await target.selectOption(params.selector, { value: params.option }, { timeout: 5000 }); + } + + const settle = await settleAfterActionAdaptive(p); + + const selectedState = await target.locator(params.selector).first().evaluate((el) => { + if (!(el instanceof HTMLSelectElement)) { + return { selectedValues: [] as string[], selectedLabels: [] as string[] }; + } + const selectedOptions = Array.from(el.selectedOptions || []); + return { + selectedValues: selectedOptions.map((opt) => opt.value), + selectedLabels: selectedOptions.map((opt) => (opt.textContent || "").trim()), + }; + }); + const optionNeedle = params.option.toLowerCase(); + const verification = verificationFromChecks( + [ + { name: "selected_values_include_option", passed: selectedState.selectedValues.includes(params.option), value: selectedState.selectedValues, expected: params.option }, + { name: "selected_labels_include_option", passed: selectedState.selectedLabels.some((label) => label.toLowerCase().includes(optionNeedle)), value: selectedState.selectedLabels, expected: params.option }, + ], + "Confirm whether the target select uses option label or value, then retry with that exact text." + ); + + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: "success", + afterUrl: afterState.url, + verificationSummary: verification.verificationSummary, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState: beforeState!, + afterState, + }); + + return { + content: [ + { + type: "text", + text: `Selected "${params.option}" in "${params.selector}". Values: ${selected.join(", ")}\nAction: ${actionId}\n${verificationLine(verification)}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}`, + }, + ], + details: { selector: params.selector, option: params.option, selected, selectedState, actionId, diff, ...settle, ...verification }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Select option failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { + content, + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_set_checked + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_set_checked", + label: "Browser Set Checked", + description: + "Check or uncheck a checkbox or radio button. More reliable than clicking for form elements where you need a specific state.", + parameters: Type.Object({ + selector: Type.String({ + description: "CSS selector targeting the checkbox or radio input", + }), + checked: Type.Boolean({ + description: "true to check, false to uncheck", + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let actionId: number | null = null; + let beforeState: CompactPageState | null = null; + try { + const { page: p } = await ensureBrowser(); + const target = getActiveTarget(); + beforeState = await captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target }); + actionId = beginTrackedAction("browser_set_checked", params, beforeState.url).id; + await target.locator(params.selector).first().setChecked(params.checked, { timeout: 10000 }); + const settle = await settleAfterActionAdaptive(p); + + const actualChecked = await target.locator(params.selector).first().isChecked().catch(() => null); + const verification = verificationFromChecks( + [ + { name: "checked_state_matches", passed: actualChecked === params.checked, value: actualChecked, expected: params.checked }, + ], + "Ensure selector points to a checkbox/radio input and retry." + ); + + const state = params.checked ? "checked" : "unchecked"; + const summary = await postActionSummary(p, target); + const jsErrors = getRecentErrors(p.url()); + const afterState = await captureCompactPageState(p, { selectors: [params.selector], includeBodyText: true, target }); + const diff = diffCompactStates(beforeState!, afterState); + lastActionBeforeState = beforeState!; + lastActionAfterState = afterState; + finishTrackedAction(actionId!, { + status: "success", + afterUrl: afterState.url, + verificationSummary: verification.verificationSummary, + warningSummary: jsErrors.trim() || undefined, + diffSummary: diff.summary, + changed: diff.changed, + beforeState: beforeState!, + afterState, + }); + + return { + content: [{ + type: "text", + text: `Set "${params.selector}" to ${state}\nAction: ${actionId}\n${verificationLine(verification)}${jsErrors}\n\nDiff:\n${formatDiffText(diff)}\n\nPage summary:\n${summary}`, + }], + details: { selector: params.selector, checked: params.checked, actualChecked, actionId, diff, ...settle, ...verification }, + }; + } catch (err: any) { + if (actionId !== null) { + finishTrackedAction(actionId, { status: "error", afterUrl: getActivePageOrNull()?.url() ?? "", error: err.message, beforeState: beforeState ?? undefined }); + } + const errorShot = await captureErrorScreenshot(getActivePageOrNull()); + const content: any[] = [{ type: "text", text: `Set checked failed: ${err.message}` }]; + if (errorShot) { + content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType }); + } + return { content, details: { error: err.message }, isError: true }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_set_viewport + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_set_viewport", + label: "Browser Set Viewport", + description: + "Resize the browser viewport to test responsive layouts at different screen sizes. Use presets for common breakpoints or specify exact pixel dimensions. Essential for verifying mobile/tablet/desktop layouts.", + parameters: Type.Object({ + preset: Type.Optional( + StringEnum(["mobile", "tablet", "desktop", "wide"] as const) + // mobile: 390×844 (iPhone 14), tablet: 768×1024 (iPad), desktop: 1280×800, wide: 1920×1080 + ), + width: Type.Optional( + Type.Number({ description: "Custom viewport width in pixels (requires height too)" }) + ), + height: Type.Optional( + Type.Number({ description: "Custom viewport height in pixels (requires width too)" }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const { page: p } = await ensureBrowser(); + + let width: number; + let height: number; + let label: string; + + if (params.preset) { + switch (params.preset) { + case "mobile": + width = 390; + height = 844; + label = "mobile (390×844)"; + break; + case "tablet": + width = 768; + height = 1024; + label = "tablet (768×1024)"; + break; + case "desktop": + width = 1280; + height = 800; + label = "desktop (1280×800)"; + break; + case "wide": + width = 1920; + height = 1080; + label = "wide (1920×1080)"; + break; + } + } else if (params.width !== undefined && params.height !== undefined) { + width = params.width; + height = params.height; + label = `custom (${width}×${height})`; + } else { + return { + content: [ + { + type: "text", + text: "Provide either a preset (mobile/tablet/desktop/wide) or both width and height.", + }, + ], + details: {}, + isError: true, + }; + } + + await p.setViewportSize({ width, height }); + + return { + content: [{ type: "text", text: `Viewport set to ${label}` }], + details: { width, height, label }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Set viewport failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_get_page_source + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_get_page_source", + label: "Browser Page Source", + description: + "Get the current HTML source of the page (or a specific element). Use when you need to inspect the actual DOM structure — verify semantic HTML, check that elements rendered correctly, debug why a selector isn't matching, or audit accessibility markup. Output is truncated for large pages.", + parameters: Type.Object({ + selector: Type.Optional( + Type.String({ + description: + "CSS selector to scope the output to a specific element (e.g. 'main', 'form', '#app'). If omitted, returns the full page HTML.", + }) + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const target = getActiveTarget(); + + let html: string; + if (params.selector) { + html = await target.locator(params.selector).first().evaluate((el: Element) => el.outerHTML); + } else { + html = await target.content(); + } + + const truncated = truncateText(html); + const scope = params.selector ? `element "${params.selector}"` : "full page"; + + return { + content: [ + { + type: "text", + text: `HTML source of ${scope}:\n\n${truncated}`, + }, + ], + details: { scope }, + }; + } catch (err: any) { + return { + content: [ + { + type: "text", + text: `Get page source failed: ${err.message}`, + }, + ], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_list_pages + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_list_pages", + label: "Browser List Pages", + description: + "List all open browser pages/tabs with their IDs, titles, URLs, and active status. Use to see what pages are available before switching.", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + // Update titles/URLs from live pages before listing + for (const entry of pageRegistry.pages) { + try { + entry.title = await entry.page.title(); + entry.url = entry.page.url(); + } catch { + // Page may have been closed + } + } + const pages = registryListPages(pageRegistry); + if (pages.length === 0) { + return { + content: [{ type: "text", text: "No pages open." }], + details: { pages: [], count: 0 }, + }; + } + const lines = pages.map((p: any) => { + const active = p.isActive ? " ← active" : ""; + const opener = p.opener !== null ? ` (opener: ${p.opener})` : ""; + return ` [${p.id}] ${p.title || "(untitled)"} — ${p.url}${opener}${active}`; + }); + return { + content: [{ type: "text", text: `${pages.length} page(s):\n${lines.join("\n")}` }], + details: { pages, count: pages.length }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `List pages failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_switch_page + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_switch_page", + label: "Browser Switch Page", + description: + "Switch the active browser page/tab by page ID. Use browser_list_pages to see available IDs. Clears any active frame selection.", + parameters: Type.Object({ + id: Type.Number({ description: "Page ID to switch to (from browser_list_pages)" }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + registrySetActive(pageRegistry, params.id); + activeFrame = null; + const entry = registryGetActive(pageRegistry); + // Bring the page to front + await entry.page.bringToFront(); + const title = await entry.page.title().catch(() => ""); + const url = entry.page.url(); + entry.title = title; + entry.url = url; + return { + content: [{ type: "text", text: `Switched to page ${params.id}: ${title || "(untitled)"} — ${url}` }], + details: { id: params.id, title, url }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Switch page failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_close_page + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_close_page", + label: "Browser Close Page", + description: + "Close a specific browser page/tab by ID. Cannot close the last remaining page. The page's close event triggers automatic registry cleanup and active-page fallback.", + parameters: Type.Object({ + id: Type.Number({ description: "Page ID to close (from browser_list_pages)" }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + if (pageRegistry.pages.length <= 1) { + return { + content: [{ type: "text", text: `Cannot close the last remaining page. Use browser_close to close the entire browser.` }], + details: { error: "last_page", pageCount: pageRegistry.pages.length }, + isError: true, + }; + } + const entry = pageRegistry.pages.find((e: any) => e.id === params.id); + if (!entry) { + const available = pageRegistry.pages.map((e: any) => e.id); + return { + content: [{ type: "text", text: `Page ${params.id} not found. Available page IDs: [${available.join(", ")}].` }], + details: { error: "not_found", available }, + isError: true, + }; + } + // Close the Playwright page — this fires the "close" event handler + // which calls registryRemovePage and handles active-page fallback + await entry.page.close(); + // Clear active frame if it belonged to the closed page + activeFrame = null; + // Refresh the page list + for (const remaining of pageRegistry.pages) { + try { + remaining.title = await remaining.page.title(); + remaining.url = remaining.page.url(); + } catch {} + } + const pages = registryListPages(pageRegistry); + const lines = pages.map((p: any) => { + const active = p.isActive ? " ← active" : ""; + return ` [${p.id}] ${p.title || "(untitled)"} — ${p.url}${active}`; + }); + return { + content: [{ type: "text", text: `Closed page ${params.id}. ${pages.length} page(s) remaining:\n${lines.join("\n")}` }], + details: { closedId: params.id, pages, count: pages.length }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Close page failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_list_frames + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_list_frames", + label: "Browser List Frames", + description: + "List all frames in the active page, including the main frame and any iframes. Shows frame name, URL, and parent frame name. Use before browser_select_frame to identify available frames.", + parameters: Type.Object({}), + + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const p = getActivePage(); + const frames = p.frames(); + const mainFrame = p.mainFrame(); + const frameList = frames.map((f, index) => { + const isMain = f === mainFrame; + const parentName = f.parentFrame()?.name() || (f.parentFrame() === mainFrame ? "main" : ""); + return { + index, + name: f.name() || (isMain ? "main" : `(unnamed-${index})`), + url: f.url(), + isMain, + parentName: isMain ? null : (parentName || "main"), + isActive: f === activeFrame, + }; + }); + const lines = frameList.map((f) => { + const main = f.isMain ? " [main]" : ""; + const active = f.isActive ? " ← selected" : ""; + const parent = f.parentName ? ` (parent: ${f.parentName})` : ""; + return ` [${f.index}] "${f.name}" — ${f.url}${main}${parent}${active}`; + }); + const activeInfo = activeFrame ? `Active frame: "${activeFrame.name() || "(unnamed)"}"` : "No frame selected (operating on main page)"; + return { + content: [{ type: "text", text: `${frameList.length} frame(s) in active page:\n${lines.join("\n")}\n\n${activeInfo}` }], + details: { frames: frameList, count: frameList.length, activeFrame: activeFrame?.name() ?? null }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `List frames failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); + + // ------------------------------------------------------------------------- + // browser_select_frame + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "browser_select_frame", + label: "Browser Select Frame", + description: + "Select a frame within the active page to operate on. Find frames by name, URL pattern, or index. Pass null or \"main\" to reset back to the main page frame. Once a frame is selected, tools like browser_evaluate, browser_find, and browser_click will operate within that frame (after T03 migration).", + parameters: Type.Object({ + name: Type.Optional(Type.String({ description: "Frame name to select. Use 'main' or 'null' to reset to main frame." })), + urlPattern: Type.Optional(Type.String({ description: "URL substring to match against frame URLs." })), + index: Type.Optional(Type.Number({ description: "Frame index from browser_list_frames." })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + await ensureBrowser(); + const p = getActivePage(); + const frames = p.frames(); + + // Reset to main frame + if (params.name === "main" || params.name === "null" || params.name === null) { + activeFrame = null; + return { + content: [{ type: "text", text: "Reset to main page frame. Tools will operate on the main page." }], + details: { activeFrame: null }, + }; + } + + // Find frame by name + if (params.name) { + const frame = frames.find((f) => f.name() === params.name); + if (!frame) { + const available = frames.map((f, i) => `[${i}] "${f.name() || "(unnamed)"}" — ${f.url()}`); + return { + content: [{ type: "text", text: `Frame with name "${params.name}" not found.\nAvailable frames:\n ${available.join("\n ")}` }], + details: { error: "frame_not_found", available }, + isError: true, + }; + } + activeFrame = frame; + return { + content: [{ type: "text", text: `Selected frame "${frame.name()}" — ${frame.url()}` }], + details: { name: frame.name(), url: frame.url() }, + }; + } + + // Find frame by URL pattern + if (params.urlPattern) { + const frame = frames.find((f) => f.url().includes(params.urlPattern!)); + if (!frame) { + const available = frames.map((f, i) => `[${i}] "${f.name() || "(unnamed)"}" — ${f.url()}`); + return { + content: [{ type: "text", text: `No frame URL matches "${params.urlPattern}".\nAvailable frames:\n ${available.join("\n ")}` }], + details: { error: "frame_not_found", available }, + isError: true, + }; + } + activeFrame = frame; + return { + content: [{ type: "text", text: `Selected frame "${frame.name() || "(unnamed)"}" — ${frame.url()}` }], + details: { name: frame.name(), url: frame.url() }, + }; + } + + // Find frame by index + if (params.index !== undefined) { + if (params.index < 0 || params.index >= frames.length) { + return { + content: [{ type: "text", text: `Frame index ${params.index} out of range. ${frames.length} frame(s) available (0-${frames.length - 1}).` }], + details: { error: "index_out_of_range", count: frames.length }, + isError: true, + }; + } + const frame = frames[params.index]; + activeFrame = frame; + return { + content: [{ type: "text", text: `Selected frame [${params.index}] "${frame.name() || "(unnamed)"}" — ${frame.url()}` }], + details: { index: params.index, name: frame.name(), url: frame.url() }, + }; + } + + // No selection criteria provided + return { + content: [{ type: "text", text: "Provide name, urlPattern, or index to select a frame. Use name='main' to reset to main frame." }], + details: { error: "no_criteria" }, + isError: true, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Select frame failed: ${err.message}` }], + details: { error: err.message }, + isError: true, + }; + } + }, + }); +} diff --git a/src/resources/extensions/browser-tools/package.json b/src/resources/extensions/browser-tools/package.json new file mode 100644 index 000000000..17849cbb4 --- /dev/null +++ b/src/resources/extensions/browser-tools/package.json @@ -0,0 +1,20 @@ +{ + "name": "pi-browser-tools", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs" + }, + "pi": { + "extensions": ["./index.ts"] + }, + "peerDependencies": { + "playwright": ">=1.40.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } +} diff --git a/src/resources/extensions/context7/index.ts b/src/resources/extensions/context7/index.ts new file mode 100644 index 000000000..0c7b88245 --- /dev/null +++ b/src/resources/extensions/context7/index.ts @@ -0,0 +1,428 @@ +/** + * Context7 Documentation Extension + * + * Replaces the context7 MCP server with a native pi extension. + * Provides two tools for the LLM: + * + * resolve_library - Search for a library by name, returns candidates with metadata + * get_library_docs - Fetch docs for a library ID, scoped to an optional query/topic + * + * API contract (verified against live API 2026-03-04): + * Search: GET /api/v2/libs/search?libraryName=&query= → { results: C7Library[] } + * Context: GET /api/v2/context?libraryId=&query=&tokens= → text/plain (markdown) + * + * Features: + * - Bearer auth via CONTEXT7_API_KEY env var (optional, increases rate limits) + * - In-session caching of search results and doc pages + * - Smart token budgeting (default 5000, configurable per call, max 10000) + * - Proper truncation guard so context is never overwhelmed + * - Custom TUI rendering for clean display in pi + * + * Setup: + * export CONTEXT7_API_KEY=your_key (get one at context7.com/dashboard) + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// ─── API types ──────────────────────────────────────────────────────────────── + +/** Shape returned by GET /api/v2/libs/search */ +interface C7SearchResponse { + results: C7Library[]; +} + +interface C7Library { + id: string; + title: string; + description?: string; + branch?: string; + lastUpdateDate?: string; + state?: string; + totalTokens?: number; + totalSnippets?: number; + stars?: number; + trustScore?: number; + benchmarkScore?: number; + versions?: string[]; +} + +// ─── In-session cache ───────────────────────────────────────────────────────── + +// Keyed by lowercased query string +const searchCache = new Map(); + +// Keyed by `${libraryId}::${query ?? ""}::${tokens}` +const docCache = new Map(); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const BASE_URL = "https://context7.com/api/v2"; + +function getApiKey(): string | undefined { + return process.env.CONTEXT7_API_KEY; +} + +function buildHeaders(): Record { + const headers: Record = { + "User-Agent": "pi-coding-agent/context7-extension", + }; + const key = getApiKey(); + if (key) headers["Authorization"] = `Bearer ${key}`; + return headers; +} + +async function apiFetchJson(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { headers: { ...buildHeaders(), Accept: "application/json" }, signal }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Context7 API ${res.status}: ${body.slice(0, 300)}`); + } + return res.json(); +} + +async function apiFetchText(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { headers: { ...buildHeaders(), Accept: "text/plain" }, signal }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Context7 API ${res.status}: ${body.slice(0, 300)}`); + } + return res.text(); +} + +/** + * Format library search results into a compact, LLM-readable string. + * Each library gets a block with the key signals for picking the best match. + */ +function formatLibraryList(libs: C7Library[], query: string): string { + if (libs.length === 0) { + return `No libraries found for "${query}". Try a different name or spelling.`; + } + + const lines: string[] = [ + `Found ${libs.length} ${libs.length === 1 ? "library" : "libraries"} matching "${query}":\n`, + ]; + + for (const lib of libs) { + let line = `• ${lib.title} (ID: ${lib.id})`; + if (lib.description) line += `\n ${lib.description}`; + + const meta: string[] = []; + if (lib.trustScore !== undefined) meta.push(`trust: ${lib.trustScore}/10`); + if (lib.benchmarkScore !== undefined) meta.push(`benchmark: ${lib.benchmarkScore.toFixed(1)}`); + if (lib.totalSnippets !== undefined) meta.push(`${lib.totalSnippets.toLocaleString()} snippets`); + if (lib.totalTokens !== undefined) meta.push(`${(lib.totalTokens / 1000).toFixed(0)}k tokens`); + if (lib.lastUpdateDate) meta.push(`updated: ${lib.lastUpdateDate.split("T")[0]}`); + if (meta.length > 0) line += `\n ${meta.join(" · ")}`; + + lines.push(line); + } + + lines.push( + "\nUse the ID (e.g. /websites/react_dev) with get_library_docs to fetch documentation.", + ); + + return lines.join("\n"); +} + +// ─── Tool details types ─────────────────────────────────────────────────────── + +interface ResolveDetails { + query: string; + resultCount: number; + cached: boolean; + error?: string; +} + +interface DocsDetails { + libraryId: string; + query?: string; + tokens: number; + cached: boolean; + truncated: boolean; + charCount: number; + error?: string; +} + +// ─── Extension ─────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ── resolve_library ────────────────────────────────────────────────────── + + pi.registerTool({ + name: "resolve_library", + label: "Resolve Library", + description: + "Search the Context7 library catalogue by name and return matching libraries with metadata. " + + "Use this to find the correct library ID before fetching documentation. " + + "Results are ranked by trustScore (0–10) and benchmarkScore — prefer the highest. " + + "If you already have a library ID (e.g. /vercel/next.js), skip this and call get_library_docs directly.", + promptSnippet: "Search Context7 for a library by name to get its ID for documentation lookup", + promptGuidelines: [ + "Call resolve_library first when the user asks about a library, package, or framework you need current docs for.", + "Choose the result with the highest trustScore and benchmarkScore when multiple matches appear.", + "Pass the user's question as the query parameter — it improves result ranking.", + ], + parameters: Type.Object({ + libraryName: Type.String({ + description: + "Library or framework name to search for, e.g. 'react', 'next.js', 'tailwindcss', 'prisma', 'langchain'", + }), + query: Type.Optional( + Type.String({ + description: + "Optional: the user's question or topic. Improves search ranking. E.g. 'how do I use server actions?'", + }), + ), + }), + + async execute(_toolCallId, params, signal, _onUpdate, _ctx) { + const cacheKey = params.libraryName.toLowerCase().trim(); + + if (searchCache.has(cacheKey)) { + const cached = searchCache.get(cacheKey)!; + return { + content: [{ type: "text", text: formatLibraryList(cached, params.libraryName) }], + details: { + query: params.libraryName, + resultCount: cached.length, + cached: true, + } as ResolveDetails, + }; + } + + const url = new URL(`${BASE_URL}/libs/search`); + url.searchParams.set("libraryName", params.libraryName); + if (params.query) url.searchParams.set("query", params.query); + + let libs: C7Library[]; + try { + const data = (await apiFetchJson(url.toString(), signal)) as C7SearchResponse; + libs = Array.isArray(data?.results) ? data.results : []; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Context7 search failed: ${msg}` }], + isError: true, + details: { query: params.libraryName, resultCount: 0, cached: false, error: msg } as ResolveDetails, + }; + } + + searchCache.set(cacheKey, libs); + + return { + content: [{ type: "text", text: formatLibraryList(libs, params.libraryName) }], + details: { query: params.libraryName, resultCount: libs.length, cached: false } as ResolveDetails, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("resolve_library ")); + text += theme.fg("accent", `"${args.libraryName}"`); + if (args.query) text += theme.fg("muted", ` — "${args.query}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + const d = result.details as ResolveDetails | undefined; + if (isPartial) return new Text(theme.fg("warning", "Searching Context7..."), 0, 0); + if (result.isError || d?.error) { + return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); + } + let text = theme.fg("success", `${d?.resultCount ?? 0} ${d?.resultCount === 1 ? "library" : "libraries"} found`); + if (d?.cached) text += theme.fg("dim", " (cached)"); + text += theme.fg("dim", ` for "${d?.query}"`); + return new Text(text, 0, 0); + }, + }); + + // ── get_library_docs ───────────────────────────────────────────────────── + + pi.registerTool({ + name: "get_library_docs", + label: "Get Library Docs", + description: + "Fetch up-to-date documentation from Context7 for a specific library. " + + "Pass the library ID from resolve_library (e.g. /websites/react_dev) and a focused topic query " + + "to get the most relevant snippets. " + + "The tokens parameter controls how much documentation to retrieve (default 5000, max 10000). " + + "A specific query (e.g. 'server actions form submission') returns better results than a broad one.", + promptSnippet: "Fetch up-to-date, version-specific documentation for a library from Context7", + promptGuidelines: [ + "Use a specific topic query for best results — e.g. 'useEffect cleanup' not just 'hooks'.", + "Start with tokens=5000. Increase to 10000 only if the first response lacks the detail you need.", + "Results are cached per-session — repeated calls for the same library+query have no API cost.", + ], + parameters: Type.Object({ + libraryId: Type.String({ + description: + "Context7 library ID from resolve_library, e.g. /websites/react_dev or /vercel/next.js", + }), + query: Type.Optional( + Type.String({ + description: + "Specific topic to focus the docs on, e.g. 'server actions', 'useEffect cleanup', 'authentication middleware'. More specific = better results.", + }), + ), + tokens: Type.Optional( + Type.Number({ + description: "Max tokens of documentation to return (default 5000, max 10000).", + minimum: 500, + maximum: 10000, + }), + ), + }), + + async execute(_toolCallId, params, signal, _onUpdate, _ctx) { + const tokens = Math.min(Math.max(params.tokens ?? 5000, 500), 10000); + // Strip accidental leading @ that some models inject + const libraryId = params.libraryId.startsWith("@") + ? params.libraryId.slice(1) + : params.libraryId; + const query = params.query?.trim() || undefined; + + const cacheKey = `${libraryId}::${query ?? ""}::${tokens}`; + + if (docCache.has(cacheKey)) { + const cached = docCache.get(cacheKey)!; + return { + content: [{ type: "text", text: cached }], + details: { + libraryId, + query, + tokens, + cached: true, + truncated: false, + charCount: cached.length, + } as DocsDetails, + }; + } + + const url = new URL(`${BASE_URL}/context`); + url.searchParams.set("libraryId", libraryId); + if (query) url.searchParams.set("query", query); + url.searchParams.set("tokens", String(tokens)); + + let rawText: string; + try { + rawText = await apiFetchText(url.toString(), signal); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Context7 doc fetch failed: ${msg}` }], + isError: true, + details: { + libraryId, + query, + tokens, + cached: false, + truncated: false, + charCount: 0, + error: msg, + } as DocsDetails, + }; + } + + if (!rawText.trim()) { + const notFound = query + ? `No documentation found for "${query}" in ${libraryId}. Try a broader query or different library ID.` + : `No documentation found for ${libraryId}. Try resolve_library to verify the library ID.`; + return { + content: [{ type: "text", text: notFound }], + details: { + libraryId, + query, + tokens, + cached: false, + truncated: false, + charCount: 0, + } as DocsDetails, + }; + } + + // Truncation guard — Context7 already respects the token budget, but be defensive + const truncation = truncateHead(rawText, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let finalText = truncation.content; + if (truncation.truncated) { + finalText += + `\n\n[Truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines` + + ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).` + + ` Use a more specific query to reduce output size.]`; + } + + docCache.set(cacheKey, finalText); + + return { + content: [{ type: "text", text: finalText }], + details: { + libraryId, + query, + tokens, + cached: false, + truncated: truncation.truncated, + charCount: finalText.length, + } as DocsDetails, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("get_library_docs ")); + text += theme.fg("accent", args.libraryId); + if (args.query) text += theme.fg("muted", ` — "${args.query}"`); + if (args.tokens && args.tokens !== 5000) text += theme.fg("dim", ` (${args.tokens} tokens)`); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial, expanded }, theme) { + const d = result.details as DocsDetails | undefined; + + if (isPartial) return new Text(theme.fg("warning", "Fetching documentation..."), 0, 0); + if (result.isError || d?.error) { + return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); + } + + let text = theme.fg("success", `${(d?.charCount ?? 0).toLocaleString()} chars`); + text += theme.fg("dim", ` · ${d?.tokens ?? 5000} token budget`); + if (d?.cached) text += theme.fg("dim", " · cached"); + if (d?.truncated) text += theme.fg("warning", " · truncated"); + text += theme.fg("dim", ` · ${d?.libraryId}`); + if (d?.query) text += theme.fg("dim", ` — "${d.query}"`); + + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + const preview = content.text.split("\n").slice(0, 12).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + if (content.text.split("\n").length > 12) { + text += "\n" + theme.fg("muted", "… (Ctrl+O to collapse)"); + } + } + } + + return new Text(text, 0, 0); + }, + }); + + // ── Startup notification ───────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + if (!getApiKey()) { + ctx.ui.notify( + "Context7: No CONTEXT7_API_KEY set. Using free tier (1000 req/month limit). " + + "Set CONTEXT7_API_KEY for higher limits.", + "warning", + ); + } + }); +} diff --git a/src/resources/extensions/context7/package.json b/src/resources/extensions/context7/package.json new file mode 100644 index 000000000..e41728a6d --- /dev/null +++ b/src/resources/extensions/context7/package.json @@ -0,0 +1,11 @@ +{ + "name": "pi-extension-context7", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/resources/extensions/get-secrets-from-user.ts b/src/resources/extensions/get-secrets-from-user.ts new file mode 100644 index 000000000..1177d9d49 --- /dev/null +++ b/src/resources/extensions/get-secrets-from-user.ts @@ -0,0 +1,352 @@ +/** + * get-secrets-from-user — paged secure env var collection + apply + * + * Collects secrets one-per-page via masked TUI input, then writes them + * to .env (local), Vercel, or Convex. No ctx.callTool, no external deps. + * Uses Node fs/promises for file I/O and pi.exec() for CLI sinks. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CollectedSecret { + key: string; + value: string | null; // null = skipped +} + +interface ToolResultDetails { + destination: string; + environment?: string; + applied: string[]; + skipped: string[]; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function maskPreview(value: string): string { + if (!value) return ""; + if (value.length <= 8) return "*".repeat(value.length); + return `${value.slice(0, 4)}${"*".repeat(Math.max(4, value.length - 8))}${value.slice(-4)}`; +} + +/** + * Replace editor visible text with masked characters while preserving ANSI cursor/sequencer codes. + */ +function maskEditorLine(line: string): string { + // Keep border / metadata lines readable. + if (line.startsWith("─")) { + return line; + } + + let output = ""; + let i = 0; + while (i < line.length) { + if (line.startsWith(CURSOR_MARKER, i)) { + output += CURSOR_MARKER; + i += CURSOR_MARKER.length; + continue; + } + + const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i)); + if (ansiMatch) { + output += ansiMatch[0]; + i += ansiMatch[0].length; + continue; + } + + const ch = line[i] as string; + output += ch === " " ? " " : "*"; + i += 1; + } + + return output; +} + +function shellEscapeSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function writeEnvKey(filePath: string, key: string, value: string): Promise { + let content = ""; + try { + content = await readFile(filePath, "utf8"); + } catch { + content = ""; + } + const escaped = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, ""); + const line = `${key}=${escaped}`; + const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, "m"); + if (regex.test(content)) { + content = content.replace(regex, line); + } else { + if (content.length > 0 && !content.endsWith("\n")) content += "\n"; + content += `${line}\n`; + } + await writeFile(filePath, content, "utf8"); +} + +// ─── Paged secure input UI ──────────────────────────────────────────────────── + +/** + * Show a single-key masked input page via ctx.ui.custom(). + * Returns the entered value, or null if skipped/cancelled. + */ +async function collectOneSecret( + ctx: { ui: any; hasUI: boolean }, + pageIndex: number, + totalPages: number, + keyName: string, + hint: string | undefined, +): Promise { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + let value = ""; + let cachedLines: string[] | undefined; + + const editorTheme: EditorTheme = { + borderColor: (s: string) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (matchesKey(data, Key.enter)) { + value = editor.getText().trim(); + done(value.length > 0 ? value : null); + return; + } + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + // ctrl+s = skip this key + if (data === "\x13") { + done(null); + return; + } + editor.handleInput(data); + refresh(); + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + add(theme.fg("dim", ` Page ${pageIndex + 1}/${totalPages} · Secure Env Setup`)); + lines.push(""); + + // Key name as big header + add(theme.fg("accent", theme.bold(` ${keyName}`))); + if (hint) { + add(theme.fg("muted", ` ${hint}`)); + } + lines.push(""); + + // Masked preview + const raw = editor.getText(); + const preview = raw.length > 0 ? maskPreview(raw) : theme.fg("dim", "(empty — press enter to skip)"); + add(theme.fg("text", ` Preview: ${preview}`)); + lines.push(""); + + // Editor + add(theme.fg("muted", " Enter value:")); + for (const line of editor.render(width - 2)) { + add(theme.fg("text", maskEditorLine(line))); + } + + lines.push(""); + add(theme.fg("dim", ` enter to confirm | ctrl+s or esc to skip | esc cancels`)); + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { cachedLines = undefined; }, + handleInput, + }; + }); +} + +// ─── Extension ──────────────────────────────────────────────────────────────── + +export default function secureEnv(pi: ExtensionAPI) { + pi.registerTool({ + name: "secure_env_collect", + label: "Secure Env Collect", + description: + "Collect one or more env vars through a paged masked-input UI, then write them to .env, Vercel, or Convex. " + + "Values are shown masked to the user (e.g. sk-ir***dgdh) and never echoed in tool output.", + promptSnippet: "Collect and apply env vars securely without asking user to edit files manually.", + promptGuidelines: [ + "NEVER ask the user to manually edit .env files, copy-paste into a terminal, or open a dashboard to set env vars. Always use secure_env_collect instead.", + "When a command fails due to a missing env var (e.g. 'OPENAI_API_KEY is not set', 'Missing required environment variable', 'Invalid API key', 'authentication required'), immediately call secure_env_collect with the missing keys before retrying.", + "When starting a new project or running setup steps that require secrets (API keys, tokens, database URLs), proactively call secure_env_collect before the first command that needs them.", + "Detect the right destination: use 'dotenv' for local dev, 'vercel' when deploying to Vercel, 'convex' when using Convex backend.", + "After secure_env_collect completes, re-run the originally blocked command to verify the fix worked.", + "Never echo, log, or repeat secret values in your responses. Only report key names and applied/skipped status.", + ], + parameters: Type.Object({ + destination: Type.Union([ + Type.Literal("dotenv"), + Type.Literal("vercel"), + Type.Literal("convex"), + ], { description: "Where to write the collected secrets" }), + keys: Type.Array( + Type.Object({ + key: Type.String({ description: "Env var name, e.g. OPENAI_API_KEY" }), + hint: Type.Optional(Type.String({ description: "Format hint shown to user, e.g. 'starts with sk-'" })), + required: Type.Optional(Type.Boolean()), + }), + { minItems: 1 }, + ), + envFilePath: Type.Optional(Type.String({ description: "Path to .env file (dotenv only). Defaults to .env in cwd." })), + environment: Type.Optional( + Type.Union([ + Type.Literal("development"), + Type.Literal("preview"), + Type.Literal("production"), + ], { description: "Target environment (vercel only)" }), + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return { + content: [{ type: "text", text: "Error: UI not available (interactive mode required for secure env collection)." }], + isError: true, + }; + } + + const collected: CollectedSecret[] = []; + + // Collect one key per page + for (let i = 0; i < params.keys.length; i++) { + const item = params.keys[i]; + const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint); + collected.push({ key: item.key, value }); + } + + const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>; + const skipped = collected.filter((c) => c.value === null).map((c) => c.key); + const applied: string[] = []; + const errors: string[] = []; + + // Apply to destination + if (params.destination === "dotenv") { + const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env"); + for (const { key, value } of provided) { + try { + await writeEnvKey(filePath, key, value); + applied.push(key); + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + if (params.destination === "vercel") { + const env = params.environment ?? "development"; + for (const { key, value } of provided) { + try { + const result = await pi.exec("sh", [ + "-c", + `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`, + ]); + if (result.code !== 0) { + errors.push(`${key}: ${result.stderr.slice(0, 200)}`); + } else { + applied.push(key); + } + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + if (params.destination === "convex") { + for (const { key, value } of provided) { + try { + const result = await pi.exec("sh", [ + "-c", + `npx convex env set ${key} ${shellEscapeSingle(value)}`, + ]); + if (result.code !== 0) { + errors.push(`${key}: ${result.stderr.slice(0, 200)}`); + } else { + applied.push(key); + } + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + const details: ToolResultDetails = { + destination: params.destination, + environment: params.environment, + applied, + skipped, + }; + + const lines = [ + `destination: ${params.destination}${params.environment ? ` (${params.environment})` : ""}`, + ...applied.map((k) => `✓ ${k}: applied`), + ...skipped.map((k) => `• ${k}: skipped`), + ...errors.map((e) => `✗ ${e}`), + ]; + + return { + content: [{ type: "text", text: lines.join("\n") }], + details, + isError: errors.length > 0 && applied.length === 0, + }; + }, + + renderCall(args, theme) { + const count = Array.isArray(args.keys) ? args.keys.length : 0; + return new Text( + theme.fg("toolTitle", theme.bold("secure_env_collect ")) + + theme.fg("muted", `→ ${args.destination}`) + + theme.fg("dim", ` ${count} key${count !== 1 ? "s" : ""}`), + 0, 0, + ); + }, + + renderResult(result, _options, theme) { + const details = result.details as ToolResultDetails | undefined; + if (!details) { + const t = result.content[0]; + return new Text(t?.type === "text" ? t.text : "", 0, 0); + } + const lines = [ + `${theme.fg("success", "✓")} ${details.destination}${details.environment ? ` (${details.environment})` : ""}`, + ...details.applied.map((k) => ` ${theme.fg("success", "✓")} ${k}: applied`), + ...details.skipped.map((k) => ` ${theme.fg("warning", "•")} ${k}: skipped`), + ]; + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts new file mode 100644 index 000000000..dbbfc80f7 --- /dev/null +++ b/src/resources/extensions/gsd/activity-log.ts @@ -0,0 +1,69 @@ +/** + * GSD Activity Log — Save raw chat sessions to .gsd/activity/ + * + * Before each context wipe in auto-mode, dumps the full session + * as JSONL. No formatting, no truncation, no information loss. + * These are debug artifacts — only read when summaries aren't enough. + * + * Diagnostic extraction is handled by session-forensics.ts. + */ + +import { writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { gsdRoot } from "./paths.js"; + +export function saveActivityLog( + ctx: ExtensionContext, + basePath: string, + unitType: string, + unitId: string, +): void { + try { + const entries = ctx.sessionManager.getEntries(); + if (!entries || entries.length === 0) return; + + const activityDir = join(gsdRoot(basePath), "activity"); + mkdirSync(activityDir, { recursive: true }); + + // Next sequence number + let maxSeq = 0; + try { + for (const f of readdirSync(activityDir)) { + const match = f.match(/^(\d+)-/); + if (match) maxSeq = Math.max(maxSeq, parseInt(match[1], 10)); + } + } catch { /* empty dir */ } + const seq = String(maxSeq + 1).padStart(3, "0"); + + const safeUnitId = unitId.replace(/\//g, "-"); + const fileName = `${seq}-${unitType}-${safeUnitId}.jsonl`; + const filePath = join(activityDir, fileName); + + const lines = entries.map(entry => JSON.stringify(entry)); + writeFileSync(filePath, lines.join("\n") + "\n", "utf-8"); + } catch { + // Don't let logging failures break auto-mode + } +} + +export function pruneActivityLogs(activityDir: string, retentionDays: number): void { + try { + const files = readdirSync(activityDir); + const entries: { seq: number; filePath: string }[] = []; + for (const f of files) { + const match = f.match(/^(\d+)-/); + if (match) entries.push({ seq: parseInt(match[1], 10), filePath: join(activityDir, f) }); + } + if (entries.length === 0) return; + const maxSeq = Math.max(...entries.map(e => e.seq)); + const cutoff = Date.now() - retentionDays * 86_400_000; + for (const entry of entries) { + if (entry.seq === maxSeq) continue; // always preserve highest-seq + try { + const mtime = statSync(entry.filePath).mtimeMs; + if (Math.floor(mtime) <= cutoff) unlinkSync(entry.filePath); + } catch { /* file vanished or stat failed — skip */ } + } + } catch { /* empty dir or readdirSync failure — skip */ } +} diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts new file mode 100644 index 000000000..bcff65f9c --- /dev/null +++ b/src/resources/extensions/gsd/auto.ts @@ -0,0 +1,2032 @@ +/** + * GSD Auto Mode — Fresh Session Per Unit + * + * State machine driven by .gsd/ files on disk. Each "unit" of work + * (plan slice, execute task, complete slice) gets a fresh session via + * the stashed ctx.newSession() pattern. + * + * The extension reads disk state after each agent_end, determines the + * next unit type, creates a fresh session, and injects a focused prompt + * telling the LLM which files to read and what to do. + */ + +import type { + ExtensionAPI, + ExtensionContext, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; + +import { deriveState } from "./state.js"; +import type { GSDState } from "./types.js"; +import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js"; +export { inlinePriorMilestoneSummary }; +import type { UatType } from "./files.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { + gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, + resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile, + relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath, + milestonesDir, resolveGsdRootFile, relGsdRootFile, +} from "./paths.js"; +import { saveActivityLog } from "./activity-log.js"; +import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js"; +import { writeLock, clearLock, readCrashLock, formatCrashInfo } from "./crash-recovery.js"; +import { + clearUnitRuntimeRecord, + formatExecuteTaskRecoveryStatus, + inspectExecuteTaskDurability, + recordUnitProgress, + readUnitRuntimeRecord, + writeUnitRuntimeRecord, +} from "./unit-runtime.js"; +import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js"; +import type { GSDPreferences } from "./preferences.js"; +import { + validatePlanBoundary, + validateExecuteBoundary, + validateCompleteBoundary, + formatValidationIssues, +} from "./observability-validator.js"; +import { ensureGitignore } from "./gitignore.js"; +import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js"; +import { + initMetrics, resetMetrics, snapshotUnitMetrics, getLedger, + getProjectTotals, formatCost, formatTokenCount, +} from "./metrics.js"; +import { join } from "node:path"; +import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { + autoCommitCurrentBranch, + ensureSliceBranch, + switchToMain, + mergeSliceToMain, +} from "./worktree.ts"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { makeUI, GLYPH, INDENT } from "../shared/ui.js"; + +// ─── State ──────────────────────────────────────────────────────────────────── + +let active = false; +let paused = false; +let verbose = false; +let cmdCtx: ExtensionCommandContext | null = null; +let basePath = ""; + +/** Track last dispatched unit to detect stuck loops */ +let lastUnit: { type: string; id: string } | null = null; +let retryCount = 0; +const MAX_RETRIES = 1; + +/** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */ +let pendingCrashRecovery: string | null = null; + +/** Dashboard tracking */ +let autoStartTime: number = 0; +let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = []; +let currentUnit: { type: string; id: string; startedAt: number } | null = null; + +/** Track current milestone to detect transitions */ +let currentMilestoneId: string | null = null; + +/** Model the user had selected before auto-mode started */ +let originalModelId: string | null = null; + +/** Progress-aware timeout supervision */ +let unitTimeoutHandle: ReturnType | null = null; +let wrapupWarningHandle: ReturnType | null = null; +let idleWatchdogHandle: ReturnType | null = null; + +/** Dashboard data for the overlay */ +export interface AutoDashboardData { + active: boolean; + paused: boolean; + startTime: number; + elapsed: number; + currentUnit: { type: string; id: string; startedAt: number } | null; + completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[]; + basePath: string; + /** Running cost and token totals from metrics ledger */ + totalCost: number; + totalTokens: number; +} + +export function getAutoDashboardData(): AutoDashboardData { + const ledger = getLedger(); + const totals = ledger ? getProjectTotals(ledger.units) : null; + return { + active, + paused, + startTime: autoStartTime, + elapsed: (active || paused) ? Date.now() - autoStartTime : 0, + currentUnit: currentUnit ? { ...currentUnit } : null, + completedUnits: [...completedUnits], + basePath, + totalCost: totals?.cost ?? 0, + totalTokens: totals?.tokens.total ?? 0, + }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function isAutoActive(): boolean { + return active; +} + +export function isAutoPaused(): boolean { + return paused; +} + +function clearUnitTimeout(): void { + if (unitTimeoutHandle) { + clearTimeout(unitTimeoutHandle); + unitTimeoutHandle = null; + } + if (wrapupWarningHandle) { + clearTimeout(wrapupWarningHandle); + wrapupWarningHandle = null; + } + if (idleWatchdogHandle) { + clearInterval(idleWatchdogHandle); + idleWatchdogHandle = null; + } +} + +export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise { + if (!active && !paused) return; + clearUnitTimeout(); + if (basePath) clearLock(basePath); + clearSkillSnapshot(); + + // Show final cost summary before resetting + const ledger = getLedger(); + if (ledger && ledger.units.length > 0) { + const totals = getProjectTotals(ledger.units); + ctx?.ui.notify( + `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, + "info", + ); + } else { + ctx?.ui.notify("Auto-mode stopped.", "info"); + } + + resetMetrics(); + active = false; + paused = false; + lastUnit = null; + currentUnit = null; + currentMilestoneId = null; + cachedSliceProgress = null; + pendingCrashRecovery = null; + ctx?.ui.setStatus("gsd-auto", undefined); + ctx?.ui.setWidget("gsd-progress", undefined); + + // Restore the user's original model + if (pi && ctx && originalModelId) { + const original = ctx.modelRegistry.find("anthropic", originalModelId); + if (original) await pi.setModel(original); + originalModelId = null; + } + + cmdCtx = null; +} + +/** + * Pause auto-mode without destroying state. Context is preserved. + * The user can interact with the agent, then `/gsd auto` resumes + * from disk state. Called when the user presses Escape during auto-mode. + */ +export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise { + if (!active) return; + clearUnitTimeout(); + if (basePath) clearLock(basePath); + active = false; + paused = true; + // Preserve: lastUnit, currentUnit, basePath, verbose, cmdCtx, + // completedUnits, autoStartTime, currentMilestoneId, originalModelId + // — all needed for resume and dashboard display + ctx?.ui.setStatus("gsd-auto", "paused"); + ctx?.ui.setWidget("gsd-progress", undefined); + ctx?.ui.notify( + "Auto-mode paused (Escape). Type to interact, or /gsd auto to resume.", + "info", + ); +} + +export async function startAuto( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + base: string, + verboseMode: boolean, +): Promise { + // If resuming from paused state, just re-activate and dispatch next unit. + // The conversation is still intact — no need to reinitialize everything. + if (paused) { + paused = false; + active = true; + verbose = verboseMode; + cmdCtx = ctx; + basePath = base; + // Re-initialize metrics in case ledger was lost during pause + if (!getLedger()) initMetrics(base); + ctx.ui.setStatus("gsd-auto", "auto"); + ctx.ui.notify("Auto-mode resumed.", "info"); + await dispatchNextUnit(ctx, pi); + return; + } + + // Ensure git repo exists — GSD needs it for branch-per-slice + try { + execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" }); + } catch { + execSync("git init", { cwd: base, stdio: "pipe" }); + } + + // Ensure .gitignore has baseline patterns + ensureGitignore(base); + + // Bootstrap .gsd/ if it doesn't exist + const gsdDir = join(base, ".gsd"); + if (!existsSync(gsdDir)) { + mkdirSync(join(gsdDir, "milestones"), { recursive: true }); + try { + execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", { + cwd: base, stdio: "pipe", + }); + } catch { /* nothing to commit */ } + } + + // Check for crash from previous session + const crashLock = readCrashLock(base); + if (crashLock) { + // Synthesize a rich recovery briefing from the surviving pi session file + // (pi writes entries incrementally, so it contains every tool call up to the crash) + const activityDir = join(gsdRoot(base), "activity"); + const recovery = synthesizeCrashRecovery( + base, crashLock.unitType, crashLock.unitId, + crashLock.sessionFile, activityDir, + ); + if (recovery && recovery.trace.toolCallCount > 0) { + pendingCrashRecovery = recovery.prompt; + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, + "warning", + ); + } else { + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`, + "warning", + ); + } + clearLock(base); + } + + const state = await deriveState(base); + + // No active work at all — start a new milestone via the discuss flow. + if (!state.activeMilestone || state.phase === "complete") { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base); + return; + } + + // Active milestone exists but has no roadmap — check if context exists. + // If context was pre-written (multi-milestone planning), auto-mode can + // research and plan it. If no context either, need user discussion. + if (state.phase === "pre-planning") { + const contextFile = resolveMilestoneFile(base, state.activeMilestone.id, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (!hasContext) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base); + return; + } + // Has context, no roadmap — auto-mode will research + plan it + } + + active = true; + verbose = verboseMode; + cmdCtx = ctx; + basePath = base; + lastUnit = null; + retryCount = 0; + autoStartTime = Date.now(); + completedUnits = []; + currentUnit = null; + currentMilestoneId = state.activeMilestone?.id ?? null; + originalModelId = ctx.model?.id ?? null; + + // Initialize metrics — loads existing ledger from disk + initMetrics(base); + + // Snapshot installed skills so we can detect new ones after research + if (resolveSkillDiscoveryMode() !== "off") { + snapshotSkills(); + } + + ctx.ui.setStatus("gsd-auto", "auto"); + const pendingCount = state.registry.filter(m => m.status !== 'complete').length; + const scopeMsg = pendingCount > 1 + ? `Will loop through ${pendingCount} milestones.` + : "Will loop until milestone complete."; + ctx.ui.notify(`Auto-mode started. ${scopeMsg}`, "info"); + + // Dispatch the first unit + await dispatchNextUnit(ctx, pi); +} + +// ─── Agent End Handler ──────────────────────────────────────────────────────── + +export async function handleAgentEnd( + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + if (!active || !cmdCtx) return; + + // Unit completed — clear its timeout + clearUnitTimeout(); + + // Small delay to let files settle (git commits, file writes) + await new Promise(r => setTimeout(r, 500)); + + // Auto-commit any dirty files the LLM left behind on the current branch. + if (currentUnit) { + try { + const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id); + if (commitMsg) { + ctx.ui.notify(`Auto-committed uncommitted changes.`, "info"); + } + } catch { + // Non-fatal + } + } + + await dispatchNextUnit(ctx, pi); +} + +// ─── Progress Widget ────────────────────────────────────────────────────── + +function unitVerb(unitType: string): string { + switch (unitType) { + case "research-milestone": + case "research-slice": return "researching"; + case "plan-milestone": + case "plan-slice": return "planning"; + case "execute-task": return "executing"; + case "complete-slice": return "completing"; + case "replan-slice": return "replanning"; + case "reassess-roadmap": return "reassessing"; + case "run-uat": return "running UAT"; + default: return unitType; + } +} + +function unitPhaseLabel(unitType: string): string { + switch (unitType) { + case "research-milestone": return "RESEARCH"; + case "research-slice": return "RESEARCH"; + case "plan-milestone": return "PLAN"; + case "plan-slice": return "PLAN"; + case "execute-task": return "EXECUTE"; + case "complete-slice": return "COMPLETE"; + case "replan-slice": return "REPLAN"; + case "reassess-roadmap": return "REASSESS"; + case "run-uat": return "UAT"; + default: return unitType.toUpperCase(); + } +} + +function peekNext(unitType: string, state: GSDState): string { + const sid = state.activeSlice?.id ?? ""; + switch (unitType) { + case "research-milestone": return "plan milestone roadmap"; + case "plan-milestone": return "research first slice"; + case "research-slice": return `plan ${sid}`; + case "plan-slice": return "execute first task"; + case "execute-task": return `continue ${sid}`; + case "complete-slice": return "reassess roadmap"; + case "replan-slice": return `re-execute ${sid}`; + case "reassess-roadmap": return "advance to next slice"; + case "run-uat": return "reassess roadmap"; + default: return ""; + } +} + + + +/** Right-align helper: build a line with left content and right content. */ +function rightAlign(left: string, right: string, width: number): string { + const leftVis = visibleWidth(left); + const rightVis = visibleWidth(right); + const gap = Math.max(1, width - leftVis - rightVis); + return truncateToWidth(left + " ".repeat(gap) + right, width); +} + +function updateProgressWidget( + ctx: ExtensionContext, + unitType: string, + unitId: string, + state: GSDState, +): void { + if (!ctx.hasUI) return; + + const verb = unitVerb(unitType); + const phaseLabel = unitPhaseLabel(unitType); + const mid = state.activeMilestone; + const slice = state.activeSlice; + const task = state.activeTask; + const next = peekNext(unitType, state); + const preferredModel = resolveModelForUnit(unitType); + + ctx.ui.setWidget("gsd-progress", (tui, theme) => { + let pulseBright = true; + let cachedLines: string[] | undefined; + let cachedWidth: number | undefined; + + const pulseTimer = setInterval(() => { + pulseBright = !pulseBright; + cachedLines = undefined; + tui.requestRender(); + }, 800); + + return { + render(width: number): string[] { + if (cachedLines && cachedWidth === width) return cachedLines; + + const ui = makeUI(theme, width); + const lines: string[] = []; + const pad = INDENT.base; + + // ── Line 1: Top bar ─────────────────────────────────────────────── + lines.push(...ui.bar()); + + const dot = pulseBright + ? theme.fg("accent", GLYPH.statusActive) + : theme.fg("dim", GLYPH.statusPending); + const elapsed = formatAutoElapsed(); + const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", "AUTO")}`; + const headerRight = elapsed ? theme.fg("dim", elapsed) : ""; + lines.push(rightAlign(headerLeft, headerRight, width)); + + lines.push(""); + + if (mid) { + lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width)); + } + + if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") { + lines.push(truncateToWidth( + `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`, + width, + )); + } + + lines.push(""); + + const target = task ? `${task.id}: ${task.title}` : unitId; + const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; + const phaseBadge = theme.fg("dim", phaseLabel); + lines.push(rightAlign(actionLeft, phaseBadge, width)); + lines.push(""); + + if (mid) { + const roadmapSlices = getRoadmapSlicesSync(); + if (roadmapSlices) { + const { done, total, activeSliceTasks } = roadmapSlices; + const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3))); + const pct = total > 0 ? done / total : 0; + const filled = Math.round(pct * barWidth); + const bar = theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); + + let meta = theme.fg("dim", `${done}/${total} slices`); + + if (activeSliceTasks && activeSliceTasks.total > 0) { + meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`); + } + + lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width)); + } + } + + lines.push(""); + + if (next) { + lines.push(truncateToWidth( + `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`, + width, + )); + } + + const hintParts: string[] = []; + if (preferredModel) hintParts.push(preferredModel); + hintParts.push("esc pause"); + hintParts.push("Ctrl+Alt+G dashboard"); + lines.push(...ui.hints(hintParts)); + + lines.push(...ui.bar()); + + cachedLines = lines; + cachedWidth = width; + return lines; + }, + invalidate() { + cachedLines = undefined; + cachedWidth = undefined; + }, + dispose() { + clearInterval(pulseTimer); + }, + }; + }); +} + +/** Format elapsed time since auto-mode started */ +function formatAutoElapsed(): string { + if (!autoStartTime) return ""; + const ms = Date.now() - autoStartTime; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +/** Cached slice progress for the widget — avoid async in render */ +let cachedSliceProgress: { + done: number; + total: number; + milestoneId: string; + /** Real task progress for the active slice, if its plan file exists */ + activeSliceTasks: { done: number; total: number } | null; +} | null = null; + +function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void { + try { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapFile) return; + const content = readFileSync(roadmapFile, "utf-8"); + const roadmap = parseRoadmap(content); + + let activeSliceTasks: { done: number; total: number } | null = null; + if (activeSid) { + try { + const planFile = resolveSliceFile(base, mid, activeSid, "PLAN"); + if (planFile && existsSync(planFile)) { + const planContent = readFileSync(planFile, "utf-8"); + const plan = parsePlan(planContent); + activeSliceTasks = { + done: plan.tasks.filter(t => t.done).length, + total: plan.tasks.length, + }; + } + } catch { + // Non-fatal — just omit task count + } + } + + cachedSliceProgress = { + done: roadmap.slices.filter(s => s.done).length, + total: roadmap.slices.length, + milestoneId: mid, + activeSliceTasks, + }; + } catch { + // Non-fatal — widget just won't show progress bar + } +} + +function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null { + return cachedSliceProgress; +} + +// ─── Core Loop ──────────────────────────────────────────────────────────────── + +async function dispatchNextUnit( + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + if (!active || !cmdCtx) return; + + let state = await deriveState(basePath); + let mid = state.activeMilestone?.id; + let midTitle = state.activeMilestone?.title; + + // Detect milestone transition + if (mid && currentMilestoneId && mid !== currentMilestoneId) { + ctx.ui.notify( + `Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, + "info", + ); + // Reset stuck detection for new milestone + lastUnit = null; + retryCount = 0; + } + if (mid) currentMilestoneId = mid; + + if (!mid) { + // Save final session before stopping + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + return; + } + + // ── Post-completion merge: merge the slice branch after complete-slice finishes ── + // The complete-slice unit writes the summary, UAT, marks roadmap [x], and commits. + // Now we switch to main and squash-merge the slice branch. + if (currentUnit?.type === "complete-slice") { + try { + const [completedMid, completedSid] = currentUnit.id.split("/"); + // Look up actual slice title from roadmap (on current branch, before switching) + const roadmapFile = resolveMilestoneFile(basePath, completedMid!, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + let sliceTitleForMerge = completedSid!; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + const sliceEntry = roadmap.slices.find(s => s.id === completedSid); + if (sliceEntry) sliceTitleForMerge = sliceEntry.title; + } + switchToMain(basePath); + const mergeResult = mergeSliceToMain( + basePath, completedMid!, completedSid!, sliceTitleForMerge, + ); + ctx.ui.notify( + `Merged ${mergeResult.branch} → main.`, + "info", + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify( + `Slice merge failed: ${message}`, + "error", + ); + // Re-derive state so dispatch can figure out what to do + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } + } + + // Determine next unit + let unitType: string; + let unitId: string; + let prompt: string; + + if (state.phase === "complete") { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + return; + } + + if (state.phase === "blocked") { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + ctx.ui.notify(`Blocked: ${state.blockers.join(", ")}. Fix and run /gsd auto.`, "warning"); + return; + } + + // ── UAT Dispatch: run-uat fires after complete-slice merge, before reassessment ── + // Ensures the UAT file and slice summary are both on main when UAT runs. + const prefs = loadEffectiveGSDPreferences()?.preferences; + + // Budget ceiling guard — pause before starting next unit if ceiling is hit + const budgetCeiling = prefs?.budget_ceiling; + if (budgetCeiling !== undefined) { + const currentLedger = getLedger(); + const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0; + if (totalCost >= budgetCeiling) { + ctx.ui.notify( + `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}). Pausing auto-mode — /gsd auto to continue.`, + "warning", + ); + await pauseAuto(ctx, pi); + return; + } + } + + const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); + // Flag: for human/mixed UAT, pause auto-mode after the prompt is sent so the user + // can perform the UAT manually. On next resume, result file will exist → skip. + let pauseAfterUatDispatch = false; + + // ── Adaptive Replanning: check if last completed slice needs reassessment ── + // After a slice completes, we reassess the roadmap before moving to the next slice. + // Skip reassessment for the final slice (milestone complete) or if already assessed. + const needsReassess = await checkNeedsReassessment(basePath, mid, state); + if (needsRunUat) { + const { sliceId, uatType } = needsRunUat; + unitType = "run-uat"; + unitId = `${mid}/${sliceId}`; + const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; + const uatContent = await loadFile(uatFile); + prompt = await buildRunUatPrompt( + mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, + ); + // For non-artifact-driven UAT types, pause after the prompt is dispatched. + // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT, + // then auto-mode pauses for human execution. On resume, result file exists → skip. + if (uatType !== "artifact-driven") { + pauseAfterUatDispatch = true; + } + } else if (needsReassess) { + unitType = "reassess-roadmap"; + unitId = `${mid}/${needsReassess.sliceId}`; + prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); + } else if (state.phase === "pre-planning") { + // Need roadmap — check if context exists + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + + if (!hasContext) { + await stopAuto(ctx, pi); + ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning"); + return; + } + + // Research before roadmap if no research exists + const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-milestone"; + unitId = mid; + prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath); + } else { + unitType = "plan-milestone"; + unitId = mid; + prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath); + } + + } else if (state.phase === "planning") { + // Slice needs planning — but research first if no research exists + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } else { + unitType = "plan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } + + } else if (state.phase === "replanning-slice") { + // Blocker discovered — replan the slice before continuing + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + unitType = "replan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + + } else if (state.phase === "executing" && state.activeTask) { + // Execute next task + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const tid = state.activeTask.id; + const tTitle = state.activeTask.title; + unitType = "execute-task"; + unitId = `${mid}/${sid}/${tid}`; + prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath); + + } else if (state.phase === "summarizing") { + // All tasks done — complete the slice + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + unitType = "complete-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + + } else if (state.phase === "completing-milestone") { + // All slices done — complete the milestone + unitType = "complete-milestone"; + unitId = mid; + prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath); + + } else { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); + return; + } + + await emitObservabilityWarnings(ctx, unitType, unitId); + + // Stuck detection — same unit dispatched again means the LLM didn't produce + // the expected artifact. Retry once (the LLM may have hit an error or run out + // of context), then stop with a diagnostic. + if (lastUnit && lastUnit.type === unitType && lastUnit.id === unitId) { + retryCount++; + + if (retryCount > MAX_RETRIES) { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + } + saveActivityLog(ctx, basePath, lastUnit.type, lastUnit.id); + + // Diagnostic: what file was expected? + const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); + await stopAuto(ctx, pi); + ctx.ui.notify( + `Stuck: ${unitType} ${unitId} fired ${retryCount + 1} times. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}\n Check .gsd/ and activity logs.`, + "error", + ); + return; + } + ctx.ui.notify( + `${unitType} ${unitId} didn't produce expected artifact. Retrying (${retryCount}/${MAX_RETRIES}).`, + "warning", + ); + } else { + retryCount = 0; + } + // Snapshot metrics + activity log for the PREVIOUS unit before we reassign. + // The session still holds the previous unit's data (newSession hasn't fired yet). + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + + completedUnits.push({ + type: currentUnit.type, + id: currentUnit.id, + startedAt: currentUnit.startedAt, + finishedAt: Date.now(), + }); + clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id); + } + + lastUnit = { type: unitType, id: unitId }; + currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: currentUnit.startedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + + // Status bar + progress widget + ctx.ui.setStatus("gsd-auto", "auto"); + if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id); + updateProgressWidget(ctx, unitType, unitId, state); + + // Ensure preconditions — create directories, branches, etc. + // so the LLM doesn't have to get these right + ensurePreconditions(unitType, unitId, basePath, state); + + // Fresh session + const result = await cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + ctx.ui.notify("New session cancelled — auto-mode stopped.", "warning"); + return; + } + + // NOTE: Slice merge happens AFTER the complete-slice unit finishes, + // not here at dispatch time. See the merge logic at the top of + // dispatchNextUnit where we check if the previous unit was complete-slice. + + // Write lock AFTER newSession so we capture the session file path. + // Pi appends entries incrementally via appendFileSync, so on crash the + // session file survives with every tool call up to the crash point. + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(basePath, unitType, unitId, completedUnits.length, sessionFile); + + // On crash recovery, prepend the full recovery briefing + // On retry (stuck detection), prepend deep diagnostic from last attempt + let finalPrompt = prompt; + if (pendingCrashRecovery) { + finalPrompt = `${pendingCrashRecovery}\n\n---\n\n${finalPrompt}`; + pendingCrashRecovery = null; + } else if (retryCount > 0) { + const diagnostic = getDeepDiagnostic(basePath); + if (diagnostic) { + finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${diagnostic}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; + } + } + + // Switch model if preferences specify one for this unit type + const preferredModelId = resolveModelForUnit(unitType); + if (preferredModelId) { + // Try to find the model across all providers + const allModels = ctx.modelRegistry.getAll(); + const model = allModels.find(m => m.id === preferredModelId); + if (model) { + const ok = await pi.setModel(model); + if (ok) { + ctx.ui.notify(`Model: ${preferredModelId}`, "info"); + } + } + } + + // Start progress-aware supervision: a soft warning, an idle watchdog, and + // a larger hard ceiling. Productive long-running tasks may continue past the + // soft timeout; only idle/stalled tasks pause early. + clearUnitTimeout(); + const supervisor = resolveAutoSupervisorConfig(); + const softTimeoutMs = supervisor.soft_timeout_minutes * 60 * 1000; + const idleTimeoutMs = supervisor.idle_timeout_minutes * 60 * 1000; + const hardTimeoutMs = supervisor.hard_timeout_minutes * 60 * 1000; + + wrapupWarningHandle = setTimeout(() => { + wrapupWarningHandle = null; + if (!active || !currentUnit) return; + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "wrapup-warning-sent", + wrapupWarningSent: true, + }); + pi.sendMessage( + { + customType: "gsd-auto-wrapup", + display: verbose, + content: [ + "**TIME BUDGET WARNING — keep going only if progress is real.**", + "This unit crossed the soft time budget.", + "If you are making progress, continue. If not, switch to wrap-up mode now:", + "1. rerun the minimal required verification", + "2. write or update the required durable artifacts", + "3. mark task or slice state on disk correctly", + "4. leave precise resume notes if anything remains unfinished", + ].join("\n"), + }, + { triggerTurn: true }, + ); + }, softTimeoutMs); + + idleWatchdogHandle = setInterval(async () => { + if (!active || !currentUnit) return; + const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); + if (!runtime) return; + if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; + + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + } + saveActivityLog(ctx, basePath, unitType, unitId); + + const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle"); + if (recovery === "recovered") return; + + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "paused", + }); + ctx.ui.notify( + `Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, 15000); + + unitTimeoutHandle = setTimeout(async () => { + unitTimeoutHandle = null; + if (!active) return; + if (currentUnit) { + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "timeout", + timeoutAt: Date.now(), + }); + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + } + saveActivityLog(ctx, basePath, unitType, unitId); + + const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard"); + if (recovery === "recovered") return; + + ctx.ui.notify( + `Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, hardTimeoutMs); + + // Inject prompt + pi.sendMessage( + { customType: "gsd-auto", content: finalPrompt, display: verbose }, + { triggerTurn: true }, + ); + + // For non-artifact-driven UAT types, pause auto-mode after sending the prompt. + // The agent will write the UAT result file surfacing it for human review, + // then on resume the result file exists and run-uat is skipped automatically. + if (pauseAfterUatDispatch) { + ctx.ui.notify( + "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", + "info", + ); + await pauseAuto(ctx, pi); + } +} + +// ─── Skill Discovery ────────────────────────────────────────────────────────── + +/** + * Build the skill discovery template variables for research prompts. + * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution. + */ +function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } { + const mode = resolveSkillDiscoveryMode(); + + if (mode === "off") { + return { + skillDiscoveryMode: "off", + skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.", + }; + } + + const autoInstall = mode === "auto"; + const instructions = ` + Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI). + For each, check if a professional agent skill already exists: + - First check \`\` in your system prompt — a skill may already be installed. + - For technologies without an installed skill, run: \`npx skills find ""\` + - Only consider skills that are **directly relevant** to core technologies — not tangentially related. + - Evaluate results by install count and relevance to the actual work.${autoInstall + ? ` + - Install relevant skills: \`npx skills add -g -y\` + - Record installed skills in the "Skills Discovered" section of your research output. + - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.` + : ` + - Note promising skills in your research output with their install commands, but do NOT install them. + - The user will decide which to install.` + }`; + + return { + skillDiscoveryMode: mode, + skillDiscoveryInstructions: instructions, + }; +} + +// ─── Inline Helpers ─────────────────────────────────────────────────────────── + +/** + * Load a file and format it for inlining into a prompt. + * Returns the content wrapped with a source path header, or a fallback + * message if the file doesn't exist. This eliminates tool calls — the LLM + * gets the content directly instead of "Read this file:". + */ +async function inlineFile( + absPath: string | null, relPath: string, label: string, +): Promise { + const content = absPath ? await loadFile(absPath) : null; + if (!content) { + return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`; + } + return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; +} + +/** + * Load a file for inlining, returning null if it doesn't exist. + * Use when the file is optional and should be omitted entirely if absent. + */ +async function inlineFileOptional( + absPath: string | null, relPath: string, label: string, +): Promise { + const content = absPath ? await loadFile(absPath) : null; + if (!content) return null; + return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; +} + +/** + * Load and inline dependency slice summaries (full content, not just paths). + */ +async function inlineDependencySummaries( + mid: string, sid: string, base: string, +): Promise { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return "- (no dependencies)"; + + const roadmap = parseRoadmap(roadmapContent); + const sliceEntry = roadmap.slices.find(s => s.id === sid); + if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)"; + + const sections: string[] = []; + for (const dep of sliceEntry.depends) { + const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY"); + const summaryContent = summaryFile ? await loadFile(summaryFile) : null; + const relPath = relSliceFile(base, mid, dep, "SUMMARY"); + if (summaryContent) { + sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`); + } else { + sections.push(`- \`${relPath}\` _(not found)_`); + } + } + return sections.join("\n\n"); +} + +/** + * Load a well-known .gsd/ root file for optional inlining. + * Handles the existsSync check internally. + */ +async function inlineGsdRootFile( + base: string, filename: string, label: string, +): Promise { + const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS"; + const absPath = resolveGsdRootFile(base, key); + if (!existsSync(absPath)) return null; + return inlineFileOptional(absPath, relGsdRootFile(key), label); +} + +// ─── Prompt Builders ────────────────────────────────────────────────────────── + +async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise { + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + + const inlined: string[] = []; + inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); + const outputAbsPath = resolveMilestoneFile(base, mid, "RESEARCH") ?? join(base, outputRelPath); + return loadPrompt("research-milestone", { + milestoneId: mid, milestoneTitle: midTitle, + milestonePath: relMilestonePath(base, mid), + contextPath: contextRel, + outputPath: outputRelPath, + outputAbsPath, + inlinedContext, + ...buildSkillDiscoveryVars(), + }); +} + +async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise { + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + const researchRel = relMilestoneFile(base, mid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); + const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research"); + if (researchInline) inlined.push(researchInline); + const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); + if (priorSummaryInline) inlined.push(priorSummaryInline); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); + const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath); + return loadPrompt("plan-milestone", { + milestoneId: mid, milestoneTitle: midTitle, + milestonePath: relMilestonePath(base, mid), + contextPath: contextRel, + researchPath: researchRel, + outputPath: outputRelPath, + outputAbsPath, + inlinedContext, + }); +} + +async function buildResearchSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); + if (contextInline) inlined.push(contextInline); + const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research"); + if (researchInline) inlined.push(researchInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + + const depContent = await inlineDependencySummaries(mid, sid, base); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH"); + const outputAbsPath = resolveSliceFile(base, mid, sid, "RESEARCH") ?? join(base, outputRelPath); + return loadPrompt("research-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + roadmapPath: roadmapRel, + contextPath: contextRel, + milestoneResearchPath: milestoneResearchRel, + outputPath: outputRelPath, + outputAbsPath, + inlinedContext, + dependencySummaries: depContent, + ...buildSkillDiscoveryVars(), + }); +} + +async function buildPlanSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); + const researchRel = relSliceFile(base, mid, sid, "RESEARCH"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research"); + if (researchInline) inlined.push(researchInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + + const depContent = await inlineDependencySummaries(mid, sid, base); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); + const outputAbsPath = resolveSliceFile(base, mid, sid, "PLAN") ?? join(base, outputRelPath); + const sliceAbsPath = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid)); + return loadPrompt("plan-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + sliceAbsPath, + roadmapPath: roadmapRel, + researchPath: researchRel, + outputPath: outputRelPath, + outputAbsPath, + inlinedContext, + dependencySummaries: depContent, + }); +} + +async function buildExecuteTaskPrompt( + mid: string, sid: string, sTitle: string, + tid: string, tTitle: string, base: string, +): Promise { + + const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base); + const priorLines = priorSummaries.length > 0 + ? priorSummaries.map(p => `- \`${p}\``).join("\n") + : "- (no prior tasks)"; + + const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN"); + const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; + const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN"); + const taskPlanInline = taskPlanContent + ? [ + "## Inlined Task Plan (authoritative local execution contract)", + `Source: \`${taskPlanRelPath}\``, + "", + taskPlanContent.trim(), + ].join("\n") + : [ + "## Inlined Task Plan (authoritative local execution contract)", + `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, + ].join("\n"); + + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; + const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN")); + + // Check for continue file (new naming or legacy) + const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE"); + const legacyContinueDir = resolveSlicePath(base, mid, sid); + const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null; + const continueContent = continueFile ? await loadFile(continueFile) : null; + const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null; + const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE"); + const resumeSection = buildResumeSection( + continueContent, + legacyContinueContent, + continueRelPath, + legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, + ); + + const carryForwardSection = await buildCarryForwardSection(priorSummaries, base); + + const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid)); + const taskSummaryAbsPath = join(sliceDirAbs, "tasks", `${tid}-SUMMARY.md`); + + return loadPrompt("execute-task", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, + planPath: relSliceFile(base, mid, sid, "PLAN"), + slicePath: relSlicePath(base, mid, sid), + taskPlanPath: taskPlanRelPath, + taskPlanInline, + slicePlanExcerpt, + carryForwardSection, + resumeSection, + priorTaskLines: priorLines, + taskSummaryAbsPath, + }); +} + +async function buildCompleteSlicePrompt( + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan")); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + + // Inline all task summaries for this slice + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); + for (const file of summaryFiles) { + const absPath = join(tDir, file); + const content = await loadFile(absPath); + const sRel = relSlicePath(base, mid, sid); + const relPath = `${sRel}/tasks/${file}`; + if (content) { + inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`); + } + } + } + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid)); + const sliceSummaryAbsPath = join(sliceDirAbs, `${sid}-SUMMARY.md`); + const sliceUatAbsPath = join(sliceDirAbs, `${sid}-UAT.md`); + + return loadPrompt("complete-slice", { + milestoneId: mid, sliceId: sid, sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + roadmapPath: roadmapRel, + inlinedContext, + sliceSummaryAbsPath, + sliceUatAbsPath, + }); +} + +async function buildCompleteMilestonePrompt( + mid: string, midTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + + // Inline all slice summaries + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + for (const slice of roadmap.slices) { + const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY"); + inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`)); + } + } + + // Inline root GSD files + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + // Inline milestone context file (milestone-level, not GSD root) + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); + if (contextInline) inlined.push(contextInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const milestoneDirAbs = resolveMilestonePath(base, mid) ?? join(base, relMilestonePath(base, mid)); + const milestoneSummaryAbsPath = join(milestoneDirAbs, `${mid}-SUMMARY.md`); + + return loadPrompt("complete-milestone", { + milestoneId: mid, + milestoneTitle: midTitle, + roadmapPath: roadmapRel, + inlinedContext, + milestoneSummaryAbsPath, + }); +} + +// ─── Replan Slice Prompt ─────────────────────────────────────────────────────── + +async function buildReplanSlicePrompt( + mid: string, midTitle: string, sid: string, sTitle: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); + inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan")); + + // Find the blocker task summary — the completed task with blocker_discovered: true + let blockerTaskId = ""; + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); + for (const file of summaryFiles) { + const absPath = join(tDir, file); + const content = await loadFile(absPath); + if (!content) continue; + const summary = parseSummary(content); + const sRel = relSlicePath(base, mid, sid); + const relPath = `${sRel}/tasks/${file}`; + if (summary.frontmatter.blocker_discovered) { + blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, ""); + inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`); + } + } + } + + // Inline decisions + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid)); + const replanAbsPath = join(sliceDirAbs, `${sid}-REPLAN.md`); + + return loadPrompt("replan-slice", { + milestoneId: mid, + sliceId: sid, + sliceTitle: sTitle, + slicePath: relSlicePath(base, mid, sid), + planPath: slicePlanRel, + blockerTaskId, + inlinedContext, + replanAbsPath, + }); +} + +// ─── Adaptive Replanning ────────────────────────────────────────────────────── + +/** + * Check if the most recently completed slice needs reassessment. + * Returns { sliceId } if reassessment is needed, null otherwise. + * + * Skips reassessment when: + * - No roadmap exists yet + * - No slices are completed + * - The last completed slice already has an assessment file + * - All slices are complete (milestone done — no point reassessing) + */ +async function checkNeedsReassessment( + base: string, mid: string, state: GSDState, +): Promise<{ sliceId: string } | null> { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + const completedSlices = roadmap.slices.filter(s => s.done); + const incompleteSlices = roadmap.slices.filter(s => !s.done); + + // No completed slices or all slices done — skip + if (completedSlices.length === 0 || incompleteSlices.length === 0) return null; + + // Check the last completed slice + const lastCompleted = completedSlices[completedSlices.length - 1]; + const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT"); + const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); + + if (hasAssessment) return null; + + // Also need a summary to reassess against + const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY"); + const hasSummary = !!(summaryFile && await loadFile(summaryFile)); + + if (!hasSummary) return null; + + return { sliceId: lastCompleted.id }; +} + +/** + * Check if the most recently completed slice needs a UAT run. + * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. + * + * Skips when: + * - No roadmap or no completed slices + * - All slices are done (milestone complete path — reassessment handles it) + * - uat_dispatch preference is not enabled + * - No UAT file exists for the slice + * - UAT result file already exists (idempotent — already ran) + */ +async function checkNeedsRunUat( + base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined, +): Promise<{ sliceId: string; uatType: UatType } | null> { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + const completedSlices = roadmap.slices.filter(s => s.done); + const incompleteSlices = roadmap.slices.filter(s => !s.done); + + // No completed slices — nothing to UAT yet + if (completedSlices.length === 0) return null; + + // All slices done — milestone complete path, skip (reassessment handles) + if (incompleteSlices.length === 0) return null; + + // uat_dispatch must be opted in + if (!prefs?.uat_dispatch) return null; + + // Take the last completed slice + const lastCompleted = completedSlices[completedSlices.length - 1]; + const sid = lastCompleted.id; + + // UAT file must exist + const uatFile = resolveSliceFile(base, mid, sid, "UAT"); + if (!uatFile) return null; + const uatContent = await loadFile(uatFile); + if (!uatContent) return null; + + // If UAT result already exists, skip (idempotent) + const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); + if (uatResultFile) { + const hasResult = !!(await loadFile(uatResultFile)); + if (hasResult) return null; + } + + // Classify UAT type; unknown type → treat as human-experience (human review) + const uatType = extractUatType(uatContent) ?? "human-experience"; + + return { sliceId: sid, uatType }; +} + +async function buildRunUatPrompt( + mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, +): Promise { + const inlined: string[] = []; + inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); + + const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY"); + if (summaryPath) { + const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`); + if (summaryInline) inlined.push(summaryInline); + } + + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const sliceDirAbs = resolveSlicePath(base, mid, sliceId) ?? join(base, relSlicePath(base, mid, sliceId)); + const uatResultAbsPath = join(sliceDirAbs, `${sliceId}-UAT-RESULT.md`); + const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT"); + const uatType = extractUatType(uatContent) ?? "human-experience"; + + return loadPrompt("run-uat", { + milestoneId: mid, + sliceId, + uatPath, + uatResultAbsPath, + uatResultPath, + uatType, + inlinedContext, + }); +} + +async function buildReassessRoadmapPrompt( + mid: string, midTitle: string, completedSliceId: string, base: string, +): Promise { + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY"); + + const inlined: string[] = []; + inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap")); + inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`)); + const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + if (projectInline) inlined.push(projectInline); + const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + if (requirementsInline) inlined.push(requirementsInline); + const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + if (decisionsInline) inlined.push(decisionsInline); + + const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; + + const assessmentRel = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); + const sliceDirAbs = resolveSlicePath(base, mid, completedSliceId) ?? join(base, relSlicePath(base, mid, completedSliceId)); + const assessmentAbsPath = join(sliceDirAbs, `${completedSliceId}-ASSESSMENT.md`); + + return loadPrompt("reassess-roadmap", { + milestoneId: mid, + milestoneTitle: midTitle, + completedSliceId, + roadmapPath: roadmapRel, + completedSliceSummaryPath: summaryRel, + assessmentPath: assessmentRel, + assessmentAbsPath, + inlinedContext, + }); +} + +function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { + if (!content) { + return [ + "## Slice Plan Excerpt", + `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, + ].join("\n"); + } + + const lines = content.split("\n"); + const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim(); + const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim(); + + const verification = extractMarkdownSection(content, "Verification"); + const observability = extractMarkdownSection(content, "Observability / Diagnostics"); + + const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; + if (goalLine) parts.push(goalLine); + if (demoLine) parts.push(demoLine); + if (verification) { + parts.push("", "### Slice Verification", verification.trim()); + } + if (observability) { + parts.push("", "### Slice Observability / Diagnostics", observability.trim()); + } + + return parts.join("\n"); +} + +function extractMarkdownSection(content: string, heading: string): string | null { + const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); + if (!match) return null; + + const start = match.index + match[0].length; + const rest = content.slice(start); + const nextHeading = rest.match(/^##\s+/m); + const end = nextHeading?.index ?? rest.length; + return rest.slice(0, end).trim(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildResumeSection( + continueContent: string | null, + legacyContinueContent: string | null, + continueRelPath: string, + legacyContinueRelPath: string | null, +): string { + const resolvedContent = continueContent ?? legacyContinueContent; + const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath; + + if (!resolvedContent || !resolvedRelPath) { + return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); + } + + const cont = parseContinue(resolvedContent); + const lines = [ + "## Resume State", + `Source: \`${resolvedRelPath}\``, + `- Status: ${cont.frontmatter.status || "in_progress"}`, + ]; + + if (cont.frontmatter.step && cont.frontmatter.totalSteps) { + lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); + } + if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); + if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); + if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); + if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); + + return lines.join("\n"); +} + +async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise { + if (priorSummaryPaths.length === 0) { + return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n"); + } + + const items = await Promise.all(priorSummaryPaths.map(async (relPath) => { + const absPath = join(base, relPath); + const content = await loadFile(absPath); + if (!content) return `- \`${relPath}\``; + + const summary = parseSummary(content); + const provided = summary.frontmatter.provides.slice(0, 2).join("; "); + const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); + const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); + const diagnostics = extractMarkdownSection(content, "Diagnostics"); + + const parts = [summary.title || relPath]; + if (summary.oneLiner) parts.push(summary.oneLiner); + if (provided) parts.push(`provides: ${provided}`); + if (decisions) parts.push(`decisions: ${decisions}`); + if (patterns) parts.push(`patterns: ${patterns}`); + if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); + + return `- \`${relPath}\` — ${parts.join(" | ")}`; + })); + + return ["## Carry-Forward Context", ...items].join("\n"); +} + +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +async function getPriorTaskSummaryPaths( + mid: string, sid: string, currentTid: string, base: string, +): Promise { + const tDir = resolveTasksDir(base, mid, sid); + if (!tDir) return []; + + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); + const currentNum = parseInt(currentTid.replace(/^T/, ""), 10); + const sRel = relSlicePath(base, mid, sid); + + return summaryFiles + .filter(f => { + const num = parseInt(f.replace(/^T/, ""), 10); + return num < currentNum; + }) + .map(f => `${sRel}/tasks/${f}`); +} + +// ─── Preconditions ──────────────────────────────────────────────────────────── + +/** + * Ensure directories, branches, and other prerequisites exist before + * dispatching a unit. The LLM should never need to mkdir or git checkout. + */ +function ensurePreconditions( + unitType: string, unitId: string, base: string, state: GSDState, +): void { + const parts = unitId.split("/"); + const mid = parts[0]!; + + // Always ensure milestone dir exists + const mDir = resolveMilestonePath(base, mid); + if (!mDir) { + const newDir = join(milestonesDir(base), mid); + mkdirSync(join(newDir, "slices"), { recursive: true }); + } + + // For slice-level units, ensure slice dir exists + if (parts.length >= 2) { + const sid = parts[1]!; + + // Re-resolve milestone path after potential creation + const mDirResolved = resolveMilestonePath(base, mid); + if (mDirResolved) { + const slicesDir = join(mDirResolved, "slices"); + const sDir = resolveDir(slicesDir, sid); + if (!sDir) { + // Create slice dir with bare ID + const newSliceDir = join(slicesDir, sid); + mkdirSync(join(newSliceDir, "tasks"), { recursive: true }); + } else { + // Ensure tasks/ subdir exists + const tasksDir = join(slicesDir, sDir, "tasks"); + if (!existsSync(tasksDir)) { + mkdirSync(tasksDir, { recursive: true }); + } + } + } + } + + if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) { + const sid = parts[1]!; + ensureSliceBranch(base, mid, sid); + } +} + +// ─── Diagnostics ────────────────────────────────────────────────────────────── + +async function emitObservabilityWarnings( + ctx: ExtensionContext, + unitType: string, + unitId: string, +): Promise { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; + + if (!mid || !sid) return; + + let issues = [] as Awaited>; + + if (unitType === "plan-slice") { + issues = await validatePlanBoundary(basePath, mid, sid); + } else if (unitType === "execute-task" && tid) { + issues = await validateExecuteBoundary(basePath, mid, sid, tid); + } else if (unitType === "complete-slice") { + issues = await validateCompleteBoundary(basePath, mid, sid); + } + + if (issues.length === 0) return; + + ctx.ui.notify( + `Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`, + "warning", + ); +} + +async function recoverTimedOutUnit( + ctx: ExtensionContext, + pi: ExtensionAPI, + unitType: string, + unitId: string, + reason: "idle" | "hard", +): Promise<"recovered" | "paused"> { + if (!currentUnit) return "paused"; + + const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); + const recoveryAttempts = runtime?.recoveryAttempts ?? 0; + const maxRecoveryAttempts = reason === "idle" ? 2 : 1; + + if (unitType === "execute-task") { + const status = await inspectExecuteTaskDurability(basePath, unitId); + if (!status) return "paused"; + + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + recovery: status, + }); + + const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced; + if (durableComplete) { + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "finalized", + recovery: status, + }); + ctx.ui.notify( + `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode.`, + "info", + ); + await dispatchNextUnit(ctx, pi); + return "recovered"; + } + + if (recoveryAttempts < maxRecoveryAttempts) { + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "recovered", + recovery: status, + recoveryAttempts: recoveryAttempts + 1, + lastRecoveryReason: reason, + lastProgressAt: Date.now(), + progressCount: (runtime?.progressCount ?? 0) + 1, + lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry", + }); + pi.sendMessage( + { + customType: "gsd-auto-timeout-recovery", + display: verbose, + content: [ + `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`, + `You are still executing ${unitType} ${unitId}.`, + `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`, + `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`, + "Do not keep exploring.", + "Immediately finish the required durable output for this unit.", + "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.", + ].join("\n"), + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + ctx.ui.notify( + `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`, + "warning", + ); + return "recovered"; + } + + const diagnostic = formatExecuteTaskRecoveryStatus(status); + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "paused", + recovery: status, + recoveryAttempts: recoveryAttempts + 1, + lastRecoveryReason: reason, + }); + ctx.ui.notify( + `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`, + "warning", + ); + return "paused"; + } + + const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact"; + if (recoveryAttempts < maxRecoveryAttempts) { + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "recovered", + recoveryAttempts: recoveryAttempts + 1, + lastRecoveryReason: reason, + lastProgressAt: Date.now(), + progressCount: (runtime?.progressCount ?? 0) + 1, + lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry", + }); + pi.sendMessage( + { + customType: "gsd-auto-timeout-recovery", + display: verbose, + content: [ + `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`, + `You are still executing ${unitType} ${unitId}.`, + `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`, + `Expected durable output: ${expected}.`, + "Stop broad exploration.", + "Write the required artifact now.", + "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.", + ].join("\n"), + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + ctx.ui.notify( + `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`, + "warning", + ); + return "recovered"; + } + + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + phase: "paused", + recoveryAttempts: recoveryAttempts + 1, + lastRecoveryReason: reason, + }); + return "paused"; +} + +function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + switch (unitType) { + case "research-milestone": + return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; + case "plan-milestone": + return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`; + case "research-slice": + return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`; + case "plan-slice": + return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; + case "execute-task": { + const tid = parts[2]; + return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; + } + case "complete-slice": + return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary written`; + case "replan-slice": + return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`; + case "reassess-roadmap": + return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`; + case "run-uat": + return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`; + case "complete-milestone": + return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`; + default: + return null; + } +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts new file mode 100644 index 000000000..dd538b118 --- /dev/null +++ b/src/resources/extensions/gsd/commands.ts @@ -0,0 +1,292 @@ +/** + * GSD Command — /gsd + * + * One command, one wizard. Routes to smart entry or status. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deriveState } from "./state.js"; +import { GSDDashboardOverlay } from "./dashboard-overlay.js"; +import { showSmartEntry, showQueue, showDiscuss } from "./guided-flow.js"; +import { startAuto, stopAuto, isAutoActive, isAutoPaused } from "./auto.js"; +import { + getGlobalGSDPreferencesPath, + getLegacyGlobalGSDPreferencesPath, + getProjectGSDPreferencesPath, + loadGlobalGSDPreferences, + loadProjectGSDPreferences, + loadEffectiveGSDPreferences, + resolveAllSkillReferences, +} from "./preferences.js"; +import { loadFile, saveFile } from "./files.js"; +import { + formatDoctorIssuesForPrompt, + formatDoctorReport, + runGSDDoctor, + selectDoctorScope, + filterDoctorIssues, +} from "./doctor.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { getSuggestedNextCommands } from "./workspace-index.ts"; + +function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + const workflow = readFileSync(workflowPath, "utf-8"); + const prompt = loadPrompt("doctor-heal", { + doctorSummary: reportText, + structuredIssues, + scopeLabel: scope ?? "active milestone / blocking scope", + doctorCommandSuffix: scope ? ` ${scope}` : "", + }); + + const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`; + + pi.sendMessage( + { customType: "gsd-doctor-heal", content, display: false }, + { triggerTurn: true }, + ); +} + +export function registerGSDCommand(pi: ExtensionAPI): void { + pi.registerCommand("gsd", { + description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor", + + getArgumentCompletions: (prefix: string) => { + const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor"]; + const parts = prefix.trim().split(/\s+/); + + if (parts.length <= 1) { + return subcommands + .filter((cmd) => cmd.startsWith(parts[0] ?? "")) + .map((cmd) => ({ value: cmd, label: cmd })); + } + + if (parts[0] === "auto" && parts.length <= 2) { + const flagPrefix = parts[1] ?? ""; + return ["--verbose"] + .filter((f) => f.startsWith(flagPrefix)) + .map((f) => ({ value: `auto ${f}`, label: f })); + } + + if (parts[0] === "prefs" && parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + return ["global", "project", "status"] + .filter((cmd) => cmd.startsWith(subPrefix)) + .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); + } + + if (parts[0] === "doctor") { + const modePrefix = parts[1] ?? ""; + const modes = ["fix", "heal", "audit"]; + + if (parts.length <= 2) { + return modes + .filter((cmd) => cmd.startsWith(modePrefix)) + .map((cmd) => ({ value: `doctor ${cmd}`, label: cmd })); + } + + return []; + } + + return []; + }, + + async handler(args: string, ctx: ExtensionCommandContext) { + const trimmed = (typeof args === "string" ? args : "").trim(); + + if (trimmed === "status") { + await handleStatus(ctx); + return; + } + + if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { + await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); + return; + } + + if (trimmed === "doctor" || trimmed.startsWith("doctor ")) { + await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi); + return; + } + + if (trimmed === "auto" || trimmed.startsWith("auto ")) { + const verboseMode = trimmed.includes("--verbose"); + await startAuto(ctx, pi, process.cwd(), verboseMode); + return; + } + + if (trimmed === "stop") { + if (!isAutoActive() && !isAutoPaused()) { + ctx.ui.notify("Auto-mode is not running.", "info"); + return; + } + await stopAuto(ctx, pi); + return; + } + + if (trimmed === "queue") { + await showQueue(ctx, pi, process.cwd()); + return; + } + + if (trimmed === "discuss") { + await showDiscuss(ctx, pi, process.cwd()); + return; + } + + if (trimmed === "") { + await showSmartEntry(ctx, pi, process.cwd()); + const next = await getSuggestedNextCommands(process.cwd()); + if (next.length > 0) { + ctx.ui.notify(`Likely next: ${next.join(" · ")}`, "info"); + } + return; + } + + ctx.ui.notify( + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], or /gsd doctor [audit|fix|heal] [M###/S##].`, + "warning", + ); + }, + }); +} + +async function handleStatus(ctx: ExtensionCommandContext): Promise { + const basePath = process.cwd(); + const state = await deriveState(basePath); + + if (state.registry.length === 0) { + ctx.ui.notify("No GSD milestones found. Run /gsd to start.", "info"); + return; + } + + await ctx.ui.custom( + (tui, theme, _kb, done) => { + return new GSDDashboardOverlay(tui, theme, () => done()); + }, + { + overlay: true, + overlayOptions: { + width: "70%", + minWidth: 60, + maxHeight: "90%", + anchor: "center", + }, + }, + ); +} + +export async function fireStatusViaCommand( + ctx: import("@mariozechner/pi-coding-agent").ExtensionContext, +): Promise { + await handleStatus(ctx as ExtensionCommandContext); +} + +async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { + const trimmed = args.trim(); + + if (trimmed === "" || trimmed === "global") { + await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); + return; + } + + if (trimmed === "project") { + await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project"); + return; + } + + if (trimmed === "status") { + const globalPrefs = loadGlobalGSDPreferences(); + const projectPrefs = loadProjectGSDPreferences(); + const canonicalGlobal = getGlobalGSDPreferencesPath(); + const legacyGlobal = getLegacyGlobalGSDPreferencesPath(); + const globalStatus = globalPrefs + ? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}` + : `missing: ${canonicalGlobal}`; + const projectStatus = projectPrefs ? `present: ${projectPrefs.path}` : `missing: ${getProjectGSDPreferencesPath()}`; + + const lines = [`GSD skill prefs — global ${globalStatus}; project ${projectStatus}`]; + + const effective = loadEffectiveGSDPreferences(); + let hasUnresolved = false; + if (effective) { + const report = resolveAllSkillReferences(effective.preferences, process.cwd()); + const resolved = [...report.resolutions.values()].filter(r => r.method !== "unresolved"); + hasUnresolved = report.warnings.length > 0; + if (resolved.length > 0 || hasUnresolved) { + lines.push(`Skills: ${resolved.length} resolved, ${report.warnings.length} unresolved`); + } + if (hasUnresolved) { + lines.push(`Unresolved: ${report.warnings.join(", ")}`); + } + } + + ctx.ui.notify(lines.join("\n"), hasUnresolved ? "warning" : "info"); + return; + } + + ctx.ui.notify("Usage: /gsd prefs [global|project|status]", "info"); +} + +async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const trimmed = args.trim(); + const parts = trimmed ? trimmed.split(/\s+/) : []; + const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor"; + const requestedScope = mode === "doctor" ? parts[0] : parts[1]; + const scope = await selectDoctorScope(process.cwd(), requestedScope); + const effectiveScope = mode === "audit" ? requestedScope : scope; + const report = await runGSDDoctor(process.cwd(), { + fix: mode === "fix" || mode === "heal", + scope: effectiveScope, + }); + + const reportText = formatDoctorReport(report, { + scope: effectiveScope, + includeWarnings: mode === "audit", + maxIssues: mode === "audit" ? 50 : 12, + title: mode === "audit" ? "GSD doctor audit." : mode === "heal" ? "GSD doctor heal prep." : undefined, + }); + + ctx.ui.notify(reportText, report.ok ? "info" : "warning"); + + if (mode === "heal") { + const unresolved = filterDoctorIssues(report.issues, { + scope: effectiveScope, + includeWarnings: true, + }); + const actionable = unresolved.filter(issue => issue.severity === "error" || issue.code === "all_tasks_done_missing_slice_uat" || issue.code === "slice_checked_missing_uat"); + if (actionable.length === 0) { + ctx.ui.notify("Doctor heal found nothing actionable to hand off to the LLM.", "info"); + return; + } + + const structuredIssues = formatDoctorIssuesForPrompt(actionable); + dispatchDoctorHeal(pi, effectiveScope, reportText, structuredIssues); + ctx.ui.notify(`Doctor heal dispatched ${actionable.length} issue(s) to the LLM.`, "info"); + } +} + +async function ensurePreferencesFile( + path: string, + ctx: ExtensionCommandContext, + scope: "global" | "project", +): Promise { + if (!existsSync(path)) { + const template = await loadFile(join(dirname(fileURLToPath(import.meta.url)), "templates", "preferences.md")); + if (!template) { + ctx.ui.notify("Could not load GSD preferences template.", "error"); + return; + } + await saveFile(path, template); + ctx.ui.notify(`Created ${scope} GSD skill preferences at ${path}`, "info"); + } else { + ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info"); + } + + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.`, "info"); +} diff --git a/src/resources/extensions/gsd/crash-recovery.ts b/src/resources/extensions/gsd/crash-recovery.ts new file mode 100644 index 000000000..ae80031fb --- /dev/null +++ b/src/resources/extensions/gsd/crash-recovery.ts @@ -0,0 +1,85 @@ +/** + * GSD Crash Recovery + * + * Detects interrupted auto-mode sessions via a lock file. + * Written on auto-start, updated on each unit dispatch, deleted on clean stop. + * If the lock file exists on next startup, the previous session crashed. + * + * The lock records the pi session file path so crash recovery can read the + * surviving JSONL (pi appends entries incrementally via appendFileSync, + * so the file on disk reflects every tool call up to the crash point). + */ + +import { writeFileSync, readFileSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { gsdRoot } from "./paths.js"; + +const LOCK_FILE = "auto.lock"; + +export interface LockData { + pid: number; + startedAt: string; + unitType: string; + unitId: string; + unitStartedAt: string; + completedUnits: number; + /** Path to the pi session JSONL file that was active when this unit started. */ + sessionFile?: string; +} + +function lockPath(basePath: string): string { + return join(gsdRoot(basePath), LOCK_FILE); +} + +/** Write or update the lock file with current auto-mode state. */ +export function writeLock( + basePath: string, + unitType: string, + unitId: string, + completedUnits: number, + sessionFile?: string, +): void { + try { + const data: LockData = { + pid: process.pid, + startedAt: new Date().toISOString(), + unitType, + unitId, + unitStartedAt: new Date().toISOString(), + completedUnits, + sessionFile, + }; + writeFileSync(lockPath(basePath), JSON.stringify(data, null, 2), "utf-8"); + } catch { /* non-fatal */ } +} + +/** Remove the lock file on clean stop. */ +export function clearLock(basePath: string): void { + try { + const p = lockPath(basePath); + if (existsSync(p)) unlinkSync(p); + } catch { /* non-fatal */ } +} + +/** Check if a crash lock exists and return its data. */ +export function readCrashLock(basePath: string): LockData | null { + try { + const p = lockPath(basePath); + if (!existsSync(p)) return null; + const raw = readFileSync(p, "utf-8"); + return JSON.parse(raw) as LockData; + } catch { + return null; + } +} + +/** Format crash info for display or injection into a prompt. */ +export function formatCrashInfo(lock: LockData): string { + return [ + `Previous auto-mode session was interrupted.`, + ` Was executing: ${lock.unitType} (${lock.unitId})`, + ` Started at: ${lock.unitStartedAt}`, + ` Units completed before crash: ${lock.completedUnits}`, + ` PID: ${lock.pid}`, + ].join("\n"); +} diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts new file mode 100644 index 000000000..6f220d5c5 --- /dev/null +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -0,0 +1,516 @@ +/** + * GSD Dashboard Overlay + * + * Full-screen overlay showing auto-mode progress: milestone/slice/task + * breakdown, current unit, completed units, timing, and activity log. + * Toggled with Ctrl+Alt+G or opened from /gsd status. + */ + +import type { Theme } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@mariozechner/pi-tui"; +import { deriveState } from "./state.js"; +import { loadFile, parseRoadmap, parsePlan } from "./files.js"; +import { resolveMilestoneFile, resolveSliceFile } from "./paths.js"; +import { getAutoDashboardData, type AutoDashboardData } from "./auto.js"; +import { + getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, + aggregateByModel, formatCost, formatTokenCount, formatCostProjection, +} from "./metrics.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; + +function formatDuration(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +function unitLabel(type: string): string { + switch (type) { + case "research-milestone": return "Research"; + case "plan-milestone": return "Plan"; + case "research-slice": return "Research"; + case "plan-slice": return "Plan"; + case "execute-task": return "Execute"; + case "complete-slice": return "Complete"; + case "reassess-roadmap": return "Reassess"; + default: return type; + } +} + +function centerLine(content: string, width: number): string { + const vis = visibleWidth(content); + if (vis >= width) return truncateToWidth(content, width); + const leftPad = Math.floor((width - vis) / 2); + return " ".repeat(leftPad) + content; +} + +function padRight(content: string, width: number): string { + const vis = visibleWidth(content); + return content + " ".repeat(Math.max(0, width - vis)); +} + +function joinColumns(left: string, right: string, width: number): string { + const leftW = visibleWidth(left); + const rightW = visibleWidth(right); + if (leftW + rightW + 2 > width) { + return truncateToWidth(`${left} ${right}`, width); + } + return left + " ".repeat(width - leftW - rightW) + right; +} + +function fitColumns(parts: string[], width: number, separator = " "): string { + const filtered = parts.filter(Boolean); + if (filtered.length === 0) return ""; + let result = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + const candidate = `${result}${separator}${filtered[i]}`; + if (visibleWidth(candidate) > width) break; + result = candidate; + } + return truncateToWidth(result, width); +} + +export class GSDDashboardOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + private refreshTimer: ReturnType; + private scrollOffset = 0; + private dashData: AutoDashboardData; + private milestoneData: MilestoneView | null = null; + private loading = true; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + onClose: () => void, + ) { + this.tui = tui; + this.theme = theme; + this.onClose = onClose; + this.dashData = getAutoDashboardData(); + + this.loadData().then(() => { + this.loading = false; + this.invalidate(); + this.tui.requestRender(); + }); + + this.refreshTimer = setInterval(() => { + this.dashData = getAutoDashboardData(); + this.loadData().then(() => { + this.invalidate(); + this.tui.requestRender(); + }); + }, 2000); + } + + private async loadData(): Promise { + const base = this.dashData.basePath || process.cwd(); + try { + const state = await deriveState(base); + if (!state.activeMilestone) { + this.milestoneData = null; + return; + } + + const mid = state.activeMilestone.id; + const view: MilestoneView = { + id: mid, + title: state.activeMilestone.title, + slices: [], + phase: state.phase, + progress: { + milestones: { + total: state.progress?.milestones.total ?? state.registry.length, + done: state.progress?.milestones.done ?? state.registry.filter(entry => entry.status === "complete").length, + }, + }, + }; + + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + for (const s of roadmap.slices) { + const sliceView: SliceView = { + id: s.id, + title: s.title, + done: s.done, + risk: s.risk, + active: state.activeSlice?.id === s.id, + tasks: [], + }; + + if (sliceView.active) { + const planFile = resolveSliceFile(base, mid, s.id, "PLAN"); + const planContent = planFile ? await loadFile(planFile) : null; + if (planContent) { + const plan = parsePlan(planContent); + sliceView.taskProgress = { + done: plan.tasks.filter(t => t.done).length, + total: plan.tasks.length, + }; + for (const t of plan.tasks) { + sliceView.tasks.push({ + id: t.id, + title: t.title, + done: t.done, + active: state.activeTask?.id === t.id, + }); + } + } + } + + view.slices.push(sliceView); + } + } + + this.milestoneData = view; + } catch { + // Don't crash the overlay + } + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("g"))) { + clearInterval(this.refreshTimer); + this.onClose(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + this.scrollOffset++; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "g") { + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "G") { + this.scrollOffset = 999; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const content = this.buildContentLines(width); + const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24); + const chromeHeight = 2; + const visibleContentRows = Math.max(1, viewportHeight - chromeHeight); + const maxScroll = Math.max(0, content.length - visibleContentRows); + this.scrollOffset = Math.min(this.scrollOffset, maxScroll); + const visibleContent = content.slice(this.scrollOffset, this.scrollOffset + visibleContentRows); + + const lines = this.wrapInBox(visibleContent, width); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + private wrapInBox(inner: string[], width: number): string[] { + const th = this.theme; + const border = (s: string) => th.fg("borderAccent", s); + const innerWidth = width - 4; + const lines: string[] = []; + + lines.push(border("╭" + "─".repeat(width - 2) + "╮")); + for (const line of inner) { + const truncated = truncateToWidth(line, innerWidth); + const padWidth = Math.max(0, innerWidth - visibleWidth(truncated)); + lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│")); + } + lines.push(border("╰" + "─".repeat(width - 2) + "╯")); + return lines; + } + + private buildContentLines(width: number): string[] { + const th = this.theme; + const shellWidth = width - 4; + const contentWidth = Math.min(shellWidth, 128); + const sidePad = Math.max(0, Math.floor((shellWidth - contentWidth) / 2)); + const leftMargin = " ".repeat(sidePad); + const lines: string[] = []; + + const row = (content = ""): string => { + const truncated = truncateToWidth(content, contentWidth); + return leftMargin + padRight(truncated, contentWidth); + }; + const blank = () => row(""); + const hr = () => row(th.fg("dim", "─".repeat(contentWidth))); + const centered = (content: string) => row(centerLine(content, contentWidth)); + + const title = th.fg("accent", th.bold("GSD Dashboard")); + const status = this.dashData.active + ? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")}` + : this.dashData.paused + ? th.fg("warning", "⏸ PAUSED") + : th.fg("dim", "idle"); + const elapsed = th.fg("dim", formatDuration(this.dashData.elapsed)); + lines.push(row(joinColumns(`${title} ${status}`, elapsed, contentWidth))); + lines.push(blank()); + + if (this.dashData.currentUnit) { + const cu = this.dashData.currentUnit; + const currentElapsed = th.fg("dim", formatDuration(Date.now() - cu.startedAt)); + lines.push(row(joinColumns( + `${th.fg("text", "Now")}: ${th.fg("accent", unitLabel(cu.type))} ${th.fg("text", cu.id)}`, + currentElapsed, + contentWidth, + ))); + lines.push(blank()); + } else if (this.dashData.paused) { + lines.push(row(th.fg("dim", "/gsd auto to resume"))); + lines.push(blank()); + } else { + lines.push(row(th.fg("dim", "No unit running · /gsd auto to start"))); + lines.push(blank()); + } + + if (this.loading) { + lines.push(centered(th.fg("dim", "Loading dashboard…"))); + return lines; + } + + if (this.milestoneData) { + const mv = this.milestoneData; + lines.push(row(th.fg("text", th.bold(`${mv.id}: ${mv.title}`)))); + lines.push(blank()); + + const totalSlices = mv.slices.length; + const doneSlices = mv.slices.filter(s => s.done).length; + const totalMilestones = mv.progress.milestones.total; + const doneMilestones = mv.progress.milestones.done; + const activeSlice = mv.slices.find(s => s.active); + + lines.push(blank()); + + if (activeSlice?.taskProgress) { + lines.push(row(this.renderProgressRow("Tasks", activeSlice.taskProgress.done, activeSlice.taskProgress.total, "accent", contentWidth))); + } + lines.push(row(this.renderProgressRow("Slices", doneSlices, totalSlices, "success", contentWidth))); + lines.push(row(this.renderProgressRow("Milestones", doneMilestones, totalMilestones, "warning", contentWidth))); + + lines.push(blank()); + + for (const s of mv.slices) { + const icon = s.done ? th.fg("success", "✓") + : s.active ? th.fg("accent", "▸") + : th.fg("dim", "○"); + const titleText = s.active ? th.fg("accent", `${s.id}: ${s.title}`) + : s.done ? th.fg("muted", `${s.id}: ${s.title}`) + : th.fg("dim", `${s.id}: ${s.title}`); + const risk = th.fg("dim", s.risk); + lines.push(row(joinColumns(` ${icon} ${titleText}`, risk, contentWidth))); + + if (s.active && s.tasks.length > 0) { + for (const t of s.tasks) { + const tIcon = t.done ? th.fg("success", "✓") + : t.active ? th.fg("warning", "▸") + : th.fg("dim", "·"); + const tTitle = t.active ? th.fg("warning", `${t.id}: ${t.title}`) + : t.done ? th.fg("muted", `${t.id}: ${t.title}`) + : th.fg("dim", `${t.id}: ${t.title}`); + lines.push(row(` ${tIcon} ${truncateToWidth(tTitle, contentWidth - 6)}`)); + } + } + } + } else { + lines.push(centered(th.fg("dim", "No active milestone."))); + } + + if (this.dashData.completedUnits.length > 0) { + lines.push(blank()); + lines.push(hr()); + lines.push(row(th.fg("text", th.bold("Completed")))); + lines.push(blank()); + + const recent = [...this.dashData.completedUnits].reverse().slice(0, 10); + for (const u of recent) { + const left = ` ${th.fg("success", "✓")} ${th.fg("muted", unitLabel(u.type))} ${th.fg("muted", u.id)}`; + const right = th.fg("dim", formatDuration(u.finishedAt - u.startedAt)); + lines.push(row(joinColumns(left, right, contentWidth))); + } + + if (this.dashData.completedUnits.length > 10) { + lines.push(row(th.fg("dim", ` ...and ${this.dashData.completedUnits.length - 10} more`))); + } + } + + const ledger = getLedger(); + if (ledger && ledger.units.length > 0) { + const totals = getProjectTotals(ledger.units); + + lines.push(blank()); + lines.push(hr()); + lines.push(row(th.fg("text", th.bold("Cost & Usage")))); + lines.push(blank()); + + lines.push(row(fitColumns([ + `${th.fg("warning", formatCost(totals.cost))} total`, + `${th.fg("text", formatTokenCount(totals.tokens.total))} tokens`, + `${th.fg("text", String(totals.toolCalls))} tools`, + `${th.fg("text", String(totals.units))} units`, + ], contentWidth, ` ${th.fg("dim", "·")} `))); + + lines.push(row(fitColumns([ + `${th.fg("dim", "in:")} ${th.fg("text", formatTokenCount(totals.tokens.input))}`, + `${th.fg("dim", "out:")} ${th.fg("text", formatTokenCount(totals.tokens.output))}`, + `${th.fg("dim", "cache-r:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheRead))}`, + `${th.fg("dim", "cache-w:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheWrite))}`, + ], contentWidth, " "))); + + const phases = aggregateByPhase(ledger.units); + if (phases.length > 0) { + lines.push(blank()); + lines.push(row(th.fg("dim", "By Phase"))); + for (const p of phases) { + const pct = totals.cost > 0 ? Math.round((p.cost / totals.cost) * 100) : 0; + const left = ` ${th.fg("text", p.phase.padEnd(14))}${th.fg("warning", formatCost(p.cost).padStart(8))}`; + const right = th.fg("dim", `${String(pct).padStart(3)}% ${formatTokenCount(p.tokens.total)} tok ${p.units} units`); + lines.push(row(joinColumns(left, right, contentWidth))); + } + } + + const slices = aggregateBySlice(ledger.units); + if (slices.length > 0) { + lines.push(blank()); + lines.push(row(th.fg("dim", "By Slice"))); + for (const s of slices) { + const pct = totals.cost > 0 ? Math.round((s.cost / totals.cost) * 100) : 0; + const left = ` ${th.fg("text", s.sliceId.padEnd(14))}${th.fg("warning", formatCost(s.cost).padStart(8))}`; + const right = th.fg("dim", `${String(pct).padStart(3)}% ${formatTokenCount(s.tokens.total)} tok ${formatDuration(s.duration)}`); + lines.push(row(joinColumns(left, right, contentWidth))); + } + } + + // Cost projection — only when active milestone data is available + if (this.milestoneData) { + const mv = this.milestoneData; + const msTotalSlices = mv.slices.length; + const msDoneSlices = mv.slices.filter(s => s.done).length; + const remainingCount = msTotalSlices - msDoneSlices; + const overlayPrefs = loadEffectiveGSDPreferences()?.preferences; + const projLines = formatCostProjection(slices, remainingCount, overlayPrefs?.budget_ceiling); + if (projLines.length > 0) { + lines.push(blank()); + for (const line of projLines) { + const colored = line.toLowerCase().includes('ceiling') + ? th.fg("warning", line) + : th.fg("dim", line); + lines.push(row(colored)); + } + } + } + + const models = aggregateByModel(ledger.units); + if (models.length > 1) { + lines.push(blank()); + lines.push(row(th.fg("dim", "By Model"))); + for (const m of models) { + const pct = totals.cost > 0 ? Math.round((m.cost / totals.cost) * 100) : 0; + const modelName = truncateToWidth(m.model, 38); + const left = ` ${th.fg("text", modelName.padEnd(38))}${th.fg("warning", formatCost(m.cost).padStart(8))}`; + const right = th.fg("dim", `${String(pct).padStart(3)}% ${m.units} units`); + lines.push(row(joinColumns(left, right, contentWidth))); + } + } + + lines.push(blank()); + lines.push(row(`${th.fg("dim", "avg/unit:")} ${th.fg("text", formatCost(totals.cost / totals.units))} ${th.fg("dim", "·")} ${th.fg("text", formatTokenCount(Math.round(totals.tokens.total / totals.units)))} tokens`)); + } + + lines.push(blank()); + lines.push(hr()); + lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close"))); + + return lines; + } + + private renderProgressRow( + label: string, + done: number, + total: number, + color: "success" | "accent" | "warning", + width: number, + ): string { + const th = this.theme; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const labelWidth = 12; + const rightWidth = 14; + const gap = 2; + const labelText = truncateToWidth(label, labelWidth, "").padEnd(labelWidth); + const ratioText = `${done}/${total}`; + const rightText = `${String(pct).padStart(3)}% ${ratioText.padStart(rightWidth - 5)}`; + const barWidth = Math.max(12, width - labelWidth - rightWidth - gap * 2); + const filled = total > 0 ? Math.round((done / total) * barWidth) : 0; + const bar = th.fg(color, "█".repeat(filled)) + th.fg("dim", "░".repeat(Math.max(0, barWidth - filled))); + return `${th.fg("dim", labelText)}${" ".repeat(gap)}${bar}${" ".repeat(gap)}${th.fg("dim", rightText)}`; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + dispose(): void { + clearInterval(this.refreshTimer); + } +} + +interface MilestoneView { + id: string; + title: string; + slices: SliceView[]; + phase: string; + progress: { + milestones: { + total: number; + done: number; + }; + }; +} + +interface SliceView { + id: string; + title: string; + done: boolean; + risk: string; + active: boolean; + tasks: TaskView[]; + taskProgress?: { done: number; total: number }; +} + +interface TaskView { + id: string; + title: string; + done: boolean; + active: boolean; +} diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md new file mode 100644 index 000000000..cc38d391c --- /dev/null +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -0,0 +1,103 @@ +# GSD Preferences Reference + +Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md` (project). + +--- + +## Notes + +- Keep this skill-first. +- Prefer explicit skill names or absolute paths. +- Use absolute paths for personal/local skills when you want zero ambiguity. +- These preferences guide which skills GSD should load and follow; they do not override higher-priority instructions in the current conversation. + +--- + +## Field Guide + +- `version`: schema version. Start at `1`. + +- `always_use_skills`: skills GSD should use whenever they are relevant. + +- `prefer_skills`: soft defaults GSD should prefer when relevant. + +- `avoid_skills`: skills GSD should avoid unless clearly needed. + +- `skill_rules`: situational rules with a human-readable `when` trigger and one or more of `use`, `prefer`, or `avoid`. + +- `custom_instructions`: extra durable instructions related to skill use. + +- `models`: per-stage model selection for auto-mode. Keys: `research`, `planning`, `execution`, `completion`. Values: model IDs (e.g. `claude-sonnet-4-6`, `claude-opus-4-6`). Omit a key to use whatever model is currently active. + +- `skill_discovery`: controls how GSD discovers and applies skills during auto-mode. Valid values: + - `auto` — skills are found and applied automatically without prompting. + - `suggest` — (default) skills are identified during research but not installed automatically. + - `off` — skill discovery is disabled entirely. + +- `auto_supervisor`: configures the auto-mode supervisor that monitors agent progress and enforces timeouts. Keys: + - `model`: model ID to use for the supervisor process (defaults to the currently active model). + - `soft_timeout_minutes`: minutes before the supervisor issues a soft warning (default: 20). + - `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10). + - `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30). + +--- + +## Best Practices + +- Keep `always_use_skills` short. +- Use `skill_rules` for situational routing, not broad personality preferences. +- Prefer skill names for stable built-in skills. +- Prefer absolute paths for local personal skills. + +--- + +## Models Example + +```yaml +--- +version: 1 +models: + research: claude-sonnet-4-6 + planning: claude-opus-4-6 + execution: claude-sonnet-4-6 + completion: claude-sonnet-4-6 +--- +``` + +Opus for planning (where architectural decisions matter most), Sonnet for everything else (faster, cheaper). Omit any key to use the currently selected model. + +--- + +## Example Variations + +**Minimal — always load a UAT skill and route Clerk tasks:** + +```yaml +--- +version: 1 +always_use_skills: + - /Users/you/.claude/skills/verify-uat +skill_rules: + - when: finishing implementation and human judgment matters + use: + - /Users/you/.claude/skills/verify-uat +--- +``` + +**Richer routing — prefer cleanup and authentication skills:** + +```yaml +--- +version: 1 +prefer_skills: + - commit-ignore +skill_rules: + - when: task involves Clerk authentication + use: + - clerk + - clerk-setup + - when: the user is looking for installable capability rather than implementation + prefer: + - find-skills +--- +``` diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts new file mode 100644 index 000000000..9bcb434e6 --- /dev/null +++ b/src/resources/extensions/gsd/doctor.ts @@ -0,0 +1,683 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js"; +import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js"; +import { deriveState, isMilestoneComplete } from "./state.js"; +import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js"; + +export type DoctorSeverity = "info" | "warning" | "error"; +export type DoctorIssueCode = + | "invalid_preferences" + | "missing_tasks_dir" + | "missing_slice_plan" + | "task_done_missing_summary" + | "task_summary_without_done_checkbox" + | "all_tasks_done_missing_slice_summary" + | "all_tasks_done_missing_slice_uat" + | "all_tasks_done_roadmap_not_checked" + | "slice_checked_missing_summary" + | "slice_checked_missing_uat" + | "all_slices_done_missing_milestone_summary" + | "task_done_must_haves_not_verified" + | "active_requirement_missing_owner" + | "blocked_requirement_missing_reason" + | "blocker_discovered_no_replan"; + +export interface DoctorIssue { + severity: DoctorSeverity; + code: DoctorIssueCode; + scope: "project" | "milestone" | "slice" | "task"; + unitId: string; + message: string; + file?: string; + fixable: boolean; +} + +export interface DoctorReport { + ok: boolean; + basePath: string; + issues: DoctorIssue[]; + fixesApplied: string[]; +} + +export interface DoctorSummary { + total: number; + errors: number; + warnings: number; + infos: number; + fixable: number; + byCode: Array<{ code: DoctorIssueCode; count: number }>; +} + +function normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const items = value.filter((item): item is string => typeof item === "string").map(item => item.trim()).filter(Boolean); + return items.length > 0 ? Array.from(new Set(items)) : undefined; +} + +function validatePreferenceShape(preferences: GSDPreferences): string[] { + const issues: string[] = []; + const listFields = ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const; + for (const field of listFields) { + const value = preferences[field]; + if (value !== undefined && !Array.isArray(value)) { + issues.push(`${field} must be a list`); + } + } + + if (preferences.skill_rules !== undefined) { + if (!Array.isArray(preferences.skill_rules)) { + issues.push("skill_rules must be a list"); + } else { + for (const [index, rule] of preferences.skill_rules.entries()) { + if (!rule || typeof rule !== "object") { + issues.push(`skill_rules[${index}] must be an object`); + continue; + } + if (typeof rule.when !== "string") { + issues.push(`skill_rules[${index}].when must be a string`); + } + for (const key of ["use", "prefer", "avoid"] as const) { + const value = (rule as Record)[key]; + if (value !== undefined && !Array.isArray(value)) { + issues.push(`skill_rules[${index}].${key} must be a list`); + } + } + } + } + } + + return issues; +} + +function buildStateMarkdown(state: Awaited>): string { + const lines: string[] = []; + lines.push("# GSD State", ""); + + const activeMilestone = state.activeMilestone + ? `${state.activeMilestone.id} — ${state.activeMilestone.title}` + : "None"; + const activeSlice = state.activeSlice + ? `${state.activeSlice.id} — ${state.activeSlice.title}` + : "None"; + + lines.push(`**Active Milestone:** ${activeMilestone}`); + lines.push(`**Active Slice:** ${activeSlice}`); + lines.push(`**Phase:** ${state.phase}`); + if (state.requirements) { + lines.push(`**Requirements Status:** ${state.requirements.active} active · ${state.requirements.validated} validated · ${state.requirements.deferred} deferred · ${state.requirements.outOfScope} out of scope`); + } + lines.push(""); + lines.push("## Milestone Registry"); + + for (const entry of state.registry) { + const glyph = entry.status === "complete" ? "✅" : entry.status === "active" ? "🔄" : "⬜"; + lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); + } + + lines.push(""); + lines.push("## Recent Decisions"); + if (state.recentDecisions.length > 0) { + for (const decision of state.recentDecisions) lines.push(`- ${decision}`); + } else { + lines.push("- None recorded"); + } + + lines.push(""); + lines.push("## Blockers"); + if (state.blockers.length > 0) { + for (const blocker of state.blockers) lines.push(`- ${blocker}`); + } else { + lines.push("- None"); + } + + lines.push(""); + lines.push("## Next Action"); + lines.push(state.nextAction || "None"); + lines.push(""); + + return lines.join("\n"); +} + +async function updateStateFile(basePath: string, fixesApplied: string[]): Promise { + const state = await deriveState(basePath); + const path = resolveGsdRootFile(basePath, "STATE"); + await saveFile(path, buildStateMarkdown(state)); + fixesApplied.push(`updated ${path}`); +} + +async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise { + const path = join(resolveSlicePath(basePath, milestoneId, sliceId) ?? relSlicePath(basePath, milestoneId, sliceId), `${sliceId}-SUMMARY.md`); + const absolute = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY") ?? join(resolveSlicePath(basePath, milestoneId, sliceId)!, `${sliceId}-SUMMARY.md`); + const content = [ + "---", + `id: ${sliceId}`, + `parent: ${milestoneId}`, + `milestone: ${milestoneId}`, + "provides: []", + "requires: []", + "affects: []", + "key_files: []", + "key_decisions: []", + "patterns_established: []", + "observability_surfaces:", + " - none yet — doctor created placeholder summary; replace with real diagnostics before treating as complete", + "drill_down_paths: []", + "duration: unknown", + "verification_result: unknown", + `completed_at: ${new Date().toISOString()}`, + "---", + "", + `# ${sliceId}: Recovery placeholder summary`, + "", + "**Doctor-created placeholder.**", + "", + "## What Happened", + "Doctor detected that all tasks were complete but the slice summary was missing. Replace this with a real compressed slice summary before relying on it.", + "", + "## Verification", + "Not re-run by doctor.", + "", + "## Deviations", + "Recovery placeholder created to restore required artifact shape.", + "", + "## Known Limitations", + "This file is intentionally incomplete and should be replaced by a real summary.", + "", + "## Follow-ups", + "- Regenerate this summary from task summaries.", + "", + "## Files Created/Modified", + `- \`${relSliceFile(basePath, milestoneId, sliceId, "SUMMARY")}\` — doctor-created placeholder summary`, + "", + "## Forward Intelligence", + "", + "### What the next slice should know", + "- Doctor had to reconstruct completion artifacts; inspect task summaries before continuing.", + "", + "### What's fragile", + "- Placeholder summary exists solely to unblock invariant checks.", + "", + "### Authoritative diagnostics", + "- Task summaries in the slice tasks/ directory — they are the actual authoritative source until this summary is rewritten.", + "", + "### What assumptions changed", + "- The system assumed completion would always write a slice summary; in practice doctor may need to restore missing artifacts.", + "", + ].join("\n"); + await saveFile(absolute, content); + fixesApplied.push(`created placeholder ${absolute}`); +} + +async function ensureSliceUatStub(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise { + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + if (!sDir) return; + const absolute = join(sDir, `${sliceId}-UAT.md`); + const content = [ + `# ${sliceId}: Recovery placeholder UAT`, + "", + `**Milestone:** ${milestoneId}`, + `**Written:** ${new Date().toISOString()}`, + "", + "## Preconditions", + "- Doctor created this placeholder because the expected UAT file was missing.", + "", + "## Smoke Test", + "- Re-run the slice verification from the slice plan before shipping.", + "", + "## Test Cases", + "### 1. Replace this placeholder", + "1. Read the slice plan and task summaries.", + "2. Write a real UAT script.", + "3. **Expected:** This placeholder is replaced with meaningful human checks.", + "", + "## Edge Cases", + "### Missing completion artifacts", + "1. Confirm the summary, roadmap checkbox, and state file are coherent.", + "2. **Expected:** GSD doctor reports no remaining completion drift for this slice.", + "", + "## Failure Signals", + "- Placeholder content still present when treating the slice as done", + "", + "## Notes for Tester", + "Doctor created this file only to restore the required artifact shape. Replace it with a real UAT script.", + "", + ].join("\n"); + await saveFile(absolute, content); + fixesApplied.push(`created placeholder ${absolute}`); +} + +async function markTaskDoneInPlan(basePath: string, milestoneId: string, sliceId: string, taskId: string, fixesApplied: string[]): Promise { + const planPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); + if (!planPath) return; + const content = await loadFile(planPath); + if (!content) return; + const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${taskId}:`, "m"), `- [x] **${taskId}:`); + if (updated !== content) { + await saveFile(planPath, updated); + fixesApplied.push(`marked ${taskId} done in ${planPath}`); + } +} + +async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise { + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + if (!roadmapPath) return; + const content = await loadFile(roadmapPath); + if (!content) return; + const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${sliceId}:`, "m"), `- [x] **${sliceId}:`); + if (updated !== content) { + await saveFile(roadmapPath, updated); + fixesApplied.push(`marked ${sliceId} done in ${roadmapPath}`); + } +} + +function matchesScope(unitId: string, scope?: string): boolean { + if (!scope) return true; + return unitId === scope || unitId.startsWith(`${scope}/`) || unitId.startsWith(`${scope}`); +} + +function auditRequirements(content: string | null): DoctorIssue[] { + if (!content) return []; + const issues: DoctorIssue[] = []; + const blocks = content.split(/^###\s+/m).slice(1); + + for (const block of blocks) { + const idMatch = block.match(/^(R\d+)/); + if (!idMatch) continue; + const requirementId = idMatch[1]; + const status = block.match(/^-\s+Status:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; + const owner = block.match(/^-\s+Primary owning slice:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; + const notes = block.match(/^-\s+Notes:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; + + if (status === "active" && (!owner || owner === "none" || owner === "none yet")) { + issues.push({ + severity: "error", + code: "active_requirement_missing_owner", + scope: "project", + unitId: requirementId, + message: `${requirementId} is Active but has no primary owning slice`, + file: relGsdRootFile("REQUIREMENTS"), + fixable: false, + }); + } + + if (status === "blocked" && !notes) { + issues.push({ + severity: "warning", + code: "blocked_requirement_missing_reason", + scope: "project", + unitId: requirementId, + message: `${requirementId} is Blocked but has no reason in Notes`, + file: relGsdRootFile("REQUIREMENTS"), + fixable: false, + }); + } + } + + return issues; +} + +export function summarizeDoctorIssues(issues: DoctorIssue[]): DoctorSummary { + const errors = issues.filter(issue => issue.severity === "error").length; + const warnings = issues.filter(issue => issue.severity === "warning").length; + const infos = issues.filter(issue => issue.severity === "info").length; + const fixable = issues.filter(issue => issue.fixable).length; + const byCodeMap = new Map(); + for (const issue of issues) { + byCodeMap.set(issue.code, (byCodeMap.get(issue.code) ?? 0) + 1); + } + const byCode = [...byCodeMap.entries()] + .map(([code, count]) => ({ code, count })) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + return { total: issues.length, errors, warnings, infos, fixable, byCode }; +} + +export async function selectDoctorScope(basePath: string, requestedScope?: string): Promise { + if (requestedScope) return requestedScope; + + const state = await deriveState(basePath); + if (state.activeMilestone?.id && state.activeSlice?.id) { + return `${state.activeMilestone.id}/${state.activeSlice.id}`; + } + if (state.activeMilestone?.id) { + return state.activeMilestone.id; + } + + const milestonesPath = milestonesDir(basePath); + if (!existsSync(milestonesPath)) return undefined; + + for (const milestone of state.registry) { + const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (!roadmapContent) continue; + const roadmap = parseRoadmap(roadmapContent); + if (!isMilestoneComplete(roadmap)) return milestone.id; + } + + return state.registry[0]?.id; +} + +export function filterDoctorIssues(issues: DoctorIssue[], options?: { scope?: string; includeWarnings?: boolean; includeHistorical?: boolean }): DoctorIssue[] { + let filtered = issues; + if (options?.scope) filtered = filtered.filter(issue => matchesScope(issue.unitId, options.scope)); + if (!options?.includeWarnings) filtered = filtered.filter(issue => issue.severity === "error"); + return filtered; +} + +export function formatDoctorReport( + report: DoctorReport, + options?: { scope?: string; includeWarnings?: boolean; maxIssues?: number; title?: string }, +): string { + const scopedIssues = filterDoctorIssues(report.issues, { + scope: options?.scope, + includeWarnings: options?.includeWarnings ?? true, + }); + const summary = summarizeDoctorIssues(scopedIssues); + const maxIssues = options?.maxIssues ?? 12; + const lines: string[] = []; + lines.push(options?.title ?? (summary.errors > 0 ? "GSD doctor found blocking issues." : "GSD doctor report.")); + lines.push(`Scope: ${options?.scope ?? "all milestones"}`); + lines.push(`Issues: ${summary.total} total · ${summary.errors} error(s) · ${summary.warnings} warning(s) · ${summary.fixable} fixable`); + + if (summary.byCode.length > 0) { + lines.push("Top issue types:"); + for (const item of summary.byCode.slice(0, 5)) { + lines.push(`- ${item.code}: ${item.count}`); + } + } + + if (scopedIssues.length > 0) { + lines.push("Priority issues:"); + for (const issue of scopedIssues.slice(0, maxIssues)) { + const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO"; + lines.push(`- [${prefix}] ${issue.unitId}: ${issue.message}${issue.file ? ` (${issue.file})` : ""}`); + } + if (scopedIssues.length > maxIssues) { + lines.push(`- ...and ${scopedIssues.length - maxIssues} more in scope`); + } + } + + if (report.fixesApplied.length > 0) { + lines.push("Fixes applied:"); + for (const fix of report.fixesApplied.slice(0, maxIssues)) lines.push(`- ${fix}`); + if (report.fixesApplied.length > maxIssues) lines.push(`- ...and ${report.fixesApplied.length - maxIssues} more`); + } + + return lines.join("\n"); +} + +export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string { + if (issues.length === 0) return "- No remaining issues in scope."; + return issues.map(issue => { + const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO"; + return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`; + }).join("\n"); +} + +export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string }): Promise { + const issues: DoctorIssue[] = []; + const fixesApplied: string[] = []; + const fix = options?.fix === true; + + const prefs = loadEffectiveGSDPreferences(); + if (prefs) { + const prefIssues = validatePreferenceShape(prefs.preferences); + for (const issue of prefIssues) { + issues.push({ + severity: "warning", + code: "invalid_preferences", + scope: "project", + unitId: "project", + message: `GSD preferences invalid: ${issue}`, + file: prefs.path, + fixable: false, + }); + } + } + + const milestonesPath = milestonesDir(basePath); + if (!existsSync(milestonesPath)) { + return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied }; + } + + const requirementsPath = resolveGsdRootFile(basePath, "REQUIREMENTS"); + const requirementsContent = await loadFile(requirementsPath); + issues.push(...auditRequirements(requirementsContent)); + + const state = await deriveState(basePath); + for (const milestone of state.registry) { + const milestoneId = milestone.id; + const milestonePath = resolveMilestonePath(basePath, milestoneId); + if (!milestonePath) continue; + + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (!roadmapContent) continue; + const roadmap = parseRoadmap(roadmapContent); + + for (const slice of roadmap.slices) { + const unitId = `${milestoneId}/${slice.id}`; + if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue; + + const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); + if (!slicePath) continue; + + const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); + if (!tasksDir) { + issues.push({ + severity: "error", + code: "missing_tasks_dir", + scope: "slice", + unitId, + message: `Missing tasks directory for ${unitId}`, + file: relSlicePath(basePath, milestoneId, slice.id), + fixable: true, + }); + if (fix) { + mkdirSync(join(slicePath, "tasks"), { recursive: true }); + fixesApplied.push(`created ${join(slicePath, "tasks")}`); + } + } + + const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN"); + const planContent = planPath ? await loadFile(planPath) : null; + const plan = planContent ? parsePlan(planContent) : null; + if (!plan) { + issues.push({ + severity: "warning", + code: "missing_slice_plan", + scope: "slice", + unitId, + message: `Slice ${unitId} has no plan file`, + file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), + fixable: false, + }); + continue; + } + + let allTasksDone = plan.tasks.length > 0; + for (const task of plan.tasks) { + const taskUnitId = `${unitId}/${task.id}`; + const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); + const hasSummary = !!(summaryPath && await loadFile(summaryPath)); + + if (task.done && !hasSummary) { + issues.push({ + severity: "error", + code: "task_done_missing_summary", + scope: "task", + unitId: taskUnitId, + message: `Task ${task.id} is marked done but summary is missing`, + file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), + fixable: false, + }); + } + + if (!task.done && hasSummary) { + issues.push({ + severity: "warning", + code: "task_summary_without_done_checkbox", + scope: "task", + unitId: taskUnitId, + message: `Task ${task.id} has a summary but is not marked done in the slice plan`, + file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), + fixable: true, + }); + if (fix) await markTaskDoneInPlan(basePath, milestoneId, slice.id, task.id, fixesApplied); + } + + // Must-have verification: done task with summary — check if must-haves are addressed + if (task.done && hasSummary) { + const taskPlanPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "PLAN"); + if (taskPlanPath) { + const taskPlanContent = await loadFile(taskPlanPath); + if (taskPlanContent) { + const mustHaves = parseTaskPlanMustHaves(taskPlanContent); + if (mustHaves.length > 0) { + const summaryContent = await loadFile(summaryPath!); + const mentionedCount = summaryContent + ? countMustHavesMentionedInSummary(mustHaves, summaryContent) + : 0; + if (mentionedCount < mustHaves.length) { + issues.push({ + severity: "warning", + code: "task_done_must_haves_not_verified", + scope: "task", + unitId: taskUnitId, + message: `Task ${task.id} has ${mustHaves.length} must-haves but summary addresses only ${mentionedCount}`, + file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), + fixable: false, + }); + } + } + } + } + } + + allTasksDone = allTasksDone && task.done; + } + + // Blocker-without-replan detection: a completed task reported blocker_discovered + // but no REPLAN.md exists yet — the slice is stuck + const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN"); + if (!replanPath) { + for (const task of plan.tasks) { + if (!task.done) continue; + const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); + if (!summaryPath) continue; + const summaryContent = await loadFile(summaryPath); + if (!summaryContent) continue; + const summary = parseSummary(summaryContent); + if (summary.frontmatter.blocker_discovered) { + issues.push({ + severity: "warning", + code: "blocker_discovered_no_replan", + scope: "slice", + unitId, + message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} — slice may be stuck`, + file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), + fixable: false, + }); + break; // one issue per slice is sufficient + } + } + } + + const sliceSummaryPath = resolveSliceFile(basePath, milestoneId, slice.id, "SUMMARY"); + const sliceUatPath = join(slicePath, `${slice.id}-UAT.md`); + const hasSliceSummary = !!(sliceSummaryPath && await loadFile(sliceSummaryPath)); + const hasSliceUat = existsSync(sliceUatPath); + + if (allTasksDone && !hasSliceSummary) { + issues.push({ + severity: "error", + code: "all_tasks_done_missing_slice_summary", + scope: "slice", + unitId, + message: `All tasks are done but ${slice.id}-SUMMARY.md is missing`, + file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"), + fixable: true, + }); + if (fix) await ensureSliceSummaryStub(basePath, milestoneId, slice.id, fixesApplied); + } + + if (allTasksDone && !hasSliceUat) { + issues.push({ + severity: "warning", + code: "all_tasks_done_missing_slice_uat", + scope: "slice", + unitId, + message: `All tasks are done but ${slice.id}-UAT.md is missing`, + file: `${relSlicePath(basePath, milestoneId, slice.id)}/${slice.id}-UAT.md`, + fixable: true, + }); + if (fix) await ensureSliceUatStub(basePath, milestoneId, slice.id, fixesApplied); + } + + if (allTasksDone && !slice.done) { + issues.push({ + severity: "error", + code: "all_tasks_done_roadmap_not_checked", + scope: "slice", + unitId, + message: `All tasks are done but roadmap still shows ${slice.id} as incomplete`, + file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), + fixable: true, + }); + if (fix && (hasSliceSummary || issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" && issue.unitId === unitId))) { + await markSliceDoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied); + } + } + + if (slice.done && !hasSliceSummary) { + issues.push({ + severity: "error", + code: "slice_checked_missing_summary", + scope: "slice", + unitId, + message: `Roadmap marks ${slice.id} complete but slice summary is missing`, + file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"), + fixable: true, + }); + } + + if (slice.done && !hasSliceUat) { + issues.push({ + severity: "warning", + code: "slice_checked_missing_uat", + scope: "slice", + unitId, + message: `Roadmap marks ${slice.id} complete but UAT file is missing`, + file: `${relSlicePath(basePath, milestoneId, slice.id)}/${slice.id}-UAT.md`, + fixable: true, + }); + } + } + + // Milestone-level check: all slices done but no milestone summary + if (isMilestoneComplete(roadmap) && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) { + issues.push({ + severity: "warning", + code: "all_slices_done_missing_milestone_summary", + scope: "milestone", + unitId: milestoneId, + message: `All slices are done but ${milestoneId}-SUMMARY.md is missing — milestone is stuck in completing-milestone phase`, + file: relMilestoneFile(basePath, milestoneId, "SUMMARY"), + fixable: false, + }); + } + } + + if (fix && fixesApplied.length > 0) { + await updateStateFile(basePath, fixesApplied); + } + + return { + ok: issues.every(issue => issue.severity !== "error"), + basePath, + issues, + fixesApplied, + }; +} + diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts new file mode 100644 index 000000000..3916a2848 --- /dev/null +++ b/src/resources/extensions/gsd/files.ts @@ -0,0 +1,730 @@ +// GSD Extension — File Parsing and I/O +// Parsers for roadmap, plan, summary, and continue files. +// Used by state derivation and the status widget. +// Pure functions, zero Pi dependencies — uses only Node built-ins. + +import { promises as fs, readdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { milestonesDir, resolveMilestoneFile, relMilestoneFile } from './paths.js'; + +import type { + Roadmap, RoadmapSliceEntry, BoundaryMapEntry, RiskLevel, + SlicePlan, TaskPlanEntry, + Summary, SummaryFrontmatter, SummaryRequires, FileModified, + Continue, ContinueFrontmatter, ContinueStatus, + RequirementCounts, +} from './types.ts'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Split markdown content into frontmatter (YAML-like) and body. + * Returns [frontmatterLines, body] where frontmatterLines is null if no frontmatter. + */ +function splitFrontmatter(content: string): [string[] | null, string] { + const trimmed = content.trimStart(); + if (!trimmed.startsWith('---')) return [null, content]; + + const afterFirst = trimmed.indexOf('\n'); + if (afterFirst === -1) return [null, content]; + + const rest = trimmed.slice(afterFirst + 1); + const endIdx = rest.indexOf('\n---'); + if (endIdx === -1) return [null, content]; + + const fmLines = rest.slice(0, endIdx).split('\n'); + const body = rest.slice(endIdx + 4).replace(/^\n+/, ''); + return [fmLines, body]; +} + +/** + * Parse YAML-like frontmatter lines into a flat key-value map. + * Handles simple scalars and arrays (lines starting with " - "). + * Handles nested objects like requires (lines with " key: value"). + */ +function parseFrontmatterMap(lines: string[]): Record { + const result: Record = {}; + let currentKey: string | null = null; + let currentArray: unknown[] | null = null; + let currentObj: Record | null = null; + + for (const line of lines) { + // Nested object property (4-space indent with key: value) + const nestedMatch = line.match(/^ (\w[\w_]*)\s*:\s*(.*)$/); + if (nestedMatch && currentArray && currentObj) { + currentObj[nestedMatch[1]] = nestedMatch[2].trim(); + continue; + } + + // Array item (2-space indent) + const arrayMatch = line.match(/^ - (.*)$/); + if (arrayMatch && currentKey) { + // If there's a pending nested object, push it + if (currentObj && Object.keys(currentObj).length > 0) { + currentArray!.push(currentObj); + } + currentObj = null; + + const val = arrayMatch[1].trim(); + if (!currentArray) currentArray = []; + + // Check if this array item starts a nested object (e.g. "- slice: S00") + const nestedStart = val.match(/^(\w[\w_]*)\s*:\s*(.*)$/); + if (nestedStart) { + currentObj = { [nestedStart[1]]: nestedStart[2].trim() }; + } else { + currentArray.push(val); + } + continue; + } + + // Flush previous key + if (currentKey) { + if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { + currentArray.push(currentObj); + currentObj = null; + } + if (currentArray) { + result[currentKey] = currentArray; + } + currentArray = null; + } + + // Top-level key: value + const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/); + if (kvMatch) { + currentKey = kvMatch[1]; + const val = kvMatch[2].trim(); + + if (val === '' || val === '[]') { + currentArray = []; + } else if (val.startsWith('[') && val.endsWith(']')) { + const inner = val.slice(1, -1).trim(); + result[currentKey] = inner ? inner.split(',').map(s => s.trim()) : []; + currentKey = null; + } else { + result[currentKey] = val; + currentKey = null; + } + } + } + + // Flush final key + if (currentKey) { + if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { + currentArray.push(currentObj); + currentObj = null; + } + if (currentArray) { + result[currentKey] = currentArray; + } + } + + return result; +} + +/** Extract the text after a heading at a given level, up to the next heading of same or higher level. */ +function extractSection(body: string, heading: string, level: number = 2): string | null { + const prefix = '#'.repeat(level) + ' '; + const regex = new RegExp(`^${prefix}${escapeRegex(heading)}\\s*$`, 'm'); + const match = regex.exec(body); + if (!match) return null; + + const start = match.index + match[0].length; + const rest = body.slice(start); + + const nextHeading = rest.match(new RegExp(`^#{1,${level}} `, 'm')); + const end = nextHeading ? nextHeading.index! : rest.length; + + return rest.slice(0, end).trim(); +} + +/** Extract all sections at a given level, returning heading → content map. */ +function extractAllSections(body: string, level: number = 2): Map { + const prefix = '#'.repeat(level) + ' '; + const regex = new RegExp(`^${prefix}(.+)$`, 'gm'); + const sections = new Map(); + const matches = [...body.matchAll(regex)]; + + for (let i = 0; i < matches.length; i++) { + const heading = matches[i][1].trim(); + const start = matches[i].index! + matches[i][0].length; + const end = i + 1 < matches.length ? matches[i + 1].index! : body.length; + sections.set(heading, body.slice(start, end).trim()); + } + + return sections; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Parse bullet list items from a text block. */ +function parseBullets(text: string): string[] { + return text.split('\n') + .map(l => l.replace(/^\s*[-*]\s+/, '').trim()) + .filter(l => l.length > 0 && !l.startsWith('#')); +} + +/** Extract key: value from bold-prefixed lines like "**Key:** Value" */ +function extractBoldField(text: string, key: string): string | null { + const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, 'm'); + const match = regex.exec(text); + return match ? match[1].trim() : null; +} + +// ─── Roadmap Parser ──────────────────────────────────────────────────────── + +export function parseRoadmap(content: string): Roadmap { + const lines = content.split('\n'); + + const h1 = lines.find(l => l.startsWith('# ')); + const title = h1 ? h1.slice(2).trim() : ''; + const vision = extractBoldField(content, 'Vision') || ''; + + const scSection = extractSection(content, 'Success Criteria', 2) || + (() => { + const idx = content.indexOf('**Success Criteria:**'); + if (idx === -1) return ''; + const rest = content.slice(idx); + const nextSection = rest.indexOf('\n---'); + const block = rest.slice(0, nextSection === -1 ? undefined : nextSection); + const firstNewline = block.indexOf('\n'); + return firstNewline === -1 ? '' : block.slice(firstNewline + 1); + })(); + const successCriteria = scSection ? parseBullets(scSection) : []; + + // Slices + const slicesSection = extractSection(content, 'Slices'); + const slices: RoadmapSliceEntry[] = []; + + if (slicesSection) { + const checkboxItems = slicesSection.split('\n'); + let currentSlice: RoadmapSliceEntry | null = null; + + for (const line of checkboxItems) { + const cbMatch = line.match(/^-\s+\[([ xX])\]\s+\*\*(\w+):\s+(.+?)\*\*\s*(.*)/); + if (cbMatch) { + if (currentSlice) slices.push(currentSlice); + + const done = cbMatch[1].toLowerCase() === 'x'; + const id = cbMatch[2]; + const sliceTitle = cbMatch[3]; + const rest = cbMatch[4]; + + const riskMatch = rest.match(/`risk:(\w+)`/); + const risk = (riskMatch ? riskMatch[1] : 'low') as RiskLevel; + + const depsMatch = rest.match(/`depends:\[([^\]]*)\]`/); + const depends = depsMatch && depsMatch[1].trim() + ? depsMatch[1].split(',').map(s => s.trim()) + : []; + + currentSlice = { id, title: sliceTitle, risk, depends, done, demo: '' }; + } else if (currentSlice && line.trim().startsWith('>')) { + const demoText = line.trim().replace(/^>\s*/, '').replace(/^After this:\s*/i, ''); + currentSlice.demo = demoText; + } + } + if (currentSlice) slices.push(currentSlice); + } + + // Boundary map + const boundaryMap: BoundaryMapEntry[] = []; + const bmSection = extractSection(content, 'Boundary Map'); + + if (bmSection) { + const h3Sections = extractAllSections(bmSection, 3); + for (const [heading, sectionContent] of h3Sections) { + const arrowMatch = heading.match(/^(\S+)\s*→\s*(\S+)/); + if (!arrowMatch) continue; + + const fromSlice = arrowMatch[1]; + const toSlice = arrowMatch[2]; + + let produces = ''; + let consumes = ''; + + const prodMatch = sectionContent.match(/^Produces:\s*\n([\s\S]*?)(?=^Consumes|$)/m); + if (prodMatch) produces = prodMatch[1].trim(); + + const consMatch = sectionContent.match(/^Consumes[^:]*:\s*\n?([\s\S]*?)$/m); + if (consMatch) consumes = consMatch[1].trim(); + if (!consumes) { + const singleCons = sectionContent.match(/^Consumes[^:]*:\s*(.+)$/m); + if (singleCons) consumes = singleCons[1].trim(); + } + + boundaryMap.push({ fromSlice, toSlice, produces, consumes }); + } + } + + return { title, vision, successCriteria, slices, boundaryMap }; +} + +// ─── Slice Plan Parser ───────────────────────────────────────────────────── + +export function parsePlan(content: string): SlicePlan { + const lines = content.split('\n'); + + const h1 = lines.find(l => l.startsWith('# ')); + let id = ''; + let title = ''; + if (h1) { + const match = h1.match(/^#\s+(\w+):\s+(.+)/); + if (match) { + id = match[1]; + title = match[2].trim(); + } else { + title = h1.slice(2).trim(); + } + } + + const goal = extractBoldField(content, 'Goal') || ''; + const demo = extractBoldField(content, 'Demo') || ''; + + const mhSection = extractSection(content, 'Must-Haves'); + const mustHaves = mhSection ? parseBullets(mhSection) : []; + + const tasksSection = extractSection(content, 'Tasks'); + const tasks: TaskPlanEntry[] = []; + + if (tasksSection) { + const taskLines = tasksSection.split('\n'); + let currentTask: TaskPlanEntry | null = null; + + for (const line of taskLines) { + const cbMatch = line.match(/^-\s+\[([ xX])\]\s+\*\*(\w+):\s+(.+?)\*\*\s*(.*)/); + if (cbMatch) { + if (currentTask) tasks.push(currentTask); + + const rest = cbMatch[4] || ''; + const estMatch = rest.match(/`est:([^`]+)`/); + const estimate = estMatch ? estMatch[1] : ''; + + currentTask = { + id: cbMatch[2], + title: cbMatch[3], + description: '', + done: cbMatch[1].toLowerCase() === 'x', + estimate, + }; + } else if (currentTask && line.match(/^\s*-\s+Files:\s*(.*)/)) { + const filesMatch = line.match(/^\s*-\s+Files:\s*(.*)/); + if (filesMatch) { + currentTask.files = filesMatch[1] + .split(',') + .map(f => f.replace(/`/g, '').trim()) + .filter(f => f.length > 0); + } + } else if (currentTask && line.match(/^\s*-\s+Verify:\s*(.*)/)) { + const verifyMatch = line.match(/^\s*-\s+Verify:\s*(.*)/); + if (verifyMatch) { + currentTask.verify = verifyMatch[1].trim(); + } + } else if (currentTask && line.trim() && !line.startsWith('#')) { + const desc = line.trim(); + if (desc) { + currentTask.description = currentTask.description + ? currentTask.description + ' ' + desc + : desc; + } + } + } + if (currentTask) tasks.push(currentTask); + } + + const filesSection = extractSection(content, 'Files Likely Touched'); + const filesLikelyTouched = filesSection ? parseBullets(filesSection) : []; + + return { id, title, goal, demo, mustHaves, tasks, filesLikelyTouched }; +} + +// ─── Summary Parser ──────────────────────────────────────────────────────── + +export function parseSummary(content: string): Summary { + const [fmLines, body] = splitFrontmatter(content); + + const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; + const frontmatter: SummaryFrontmatter = { + id: (fm.id as string) || '', + parent: (fm.parent as string) || '', + milestone: (fm.milestone as string) || '', + provides: (fm.provides as string[]) || [], + requires: ((fm.requires as Array>) || []).map(r => ({ + slice: r.slice || '', + provides: r.provides || '', + })), + affects: (fm.affects as string[]) || [], + key_files: (fm.key_files as string[]) || [], + key_decisions: (fm.key_decisions as string[]) || [], + patterns_established: (fm.patterns_established as string[]) || [], + drill_down_paths: (fm.drill_down_paths as string[]) || [], + observability_surfaces: (fm.observability_surfaces as string[]) || [], + duration: (fm.duration as string) || '', + verification_result: (fm.verification_result as string) || 'untested', + completed_at: (fm.completed_at as string) || '', + blocker_discovered: fm.blocker_discovered === 'true' || fm.blocker_discovered === true, + }; + + const bodyLines = body.split('\n'); + const h1 = bodyLines.find(l => l.startsWith('# ')); + const title = h1 ? h1.slice(2).trim() : ''; + + const h1Idx = bodyLines.indexOf(h1 || ''); + let oneLiner = ''; + for (let i = h1Idx + 1; i < bodyLines.length; i++) { + const line = bodyLines[i].trim(); + if (!line) continue; + if (line.startsWith('**') && line.endsWith('**')) { + oneLiner = line.slice(2, -2); + } + break; + } + + const whatHappened = extractSection(body, 'What Happened') || ''; + const deviations = extractSection(body, 'Deviations') || ''; + + const filesSection = extractSection(body, 'Files Created/Modified') || extractSection(body, 'Files Modified'); + const filesModified: FileModified[] = []; + if (filesSection) { + for (const line of filesSection.split('\n')) { + const trimmed = line.replace(/^\s*[-*]\s+/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const fileMatch = trimmed.match(/^`([^`]+)`\s*[—–-]\s*(.+)/); + if (fileMatch) { + filesModified.push({ path: fileMatch[1], description: fileMatch[2].trim() }); + } + } + } + + return { frontmatter, title, oneLiner, whatHappened, deviations, filesModified }; +} + +// ─── Continue Parser ─────────────────────────────────────────────────────── + +export function parseContinue(content: string): Continue { + const [fmLines, body] = splitFrontmatter(content); + + const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; + const frontmatter: ContinueFrontmatter = { + milestone: (fm.milestone as string) || '', + slice: (fm.slice as string) || '', + task: (fm.task as string) || '', + step: typeof fm.step === 'string' ? parseInt(fm.step) : (fm.step as number) || 0, + totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps) : (fm.total_steps as number) || + (typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps) : (fm.totalSteps as number) || 0), + status: ((fm.status as string) || 'in_progress') as ContinueStatus, + savedAt: (fm.saved_at as string) || (fm.savedAt as string) || '', + }; + + const completedWork = extractSection(body, 'Completed Work') || ''; + const remainingWork = extractSection(body, 'Remaining Work') || ''; + const decisions = extractSection(body, 'Decisions Made') || ''; + const context = extractSection(body, 'Context') || ''; + const nextAction = extractSection(body, 'Next Action') || ''; + + return { frontmatter, completedWork, remainingWork, decisions, context, nextAction }; +} + +// ─── Continue Formatter ──────────────────────────────────────────────────── + +function formatFrontmatter(data: Record): string { + const lines: string[] = ['---']; + + for (const [key, value] of Object.entries(data)) { + if (value === undefined || value === null) continue; + + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${key}: []`); + } else if (typeof value[0] === 'object' && value[0] !== null) { + lines.push(`${key}:`); + for (const obj of value) { + const entries = Object.entries(obj as Record); + if (entries.length > 0) { + lines.push(` - ${entries[0][0]}: ${entries[0][1]}`); + for (let i = 1; i < entries.length; i++) { + lines.push(` ${entries[i][0]}: ${entries[i][1]}`); + } + } + } + } else { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${item}`); + } + } + } else { + lines.push(`${key}: ${value}`); + } + } + + lines.push('---'); + return lines.join('\n'); +} + +export function formatContinue(cont: Continue): string { + const fm = cont.frontmatter; + const fmData: Record = { + milestone: fm.milestone, + slice: fm.slice, + task: fm.task, + step: fm.step, + total_steps: fm.totalSteps, + status: fm.status, + saved_at: fm.savedAt, + }; + + const lines: string[] = []; + lines.push(formatFrontmatter(fmData)); + lines.push(''); + lines.push('## Completed Work'); + lines.push(cont.completedWork); + lines.push(''); + lines.push('## Remaining Work'); + lines.push(cont.remainingWork); + lines.push(''); + lines.push('## Decisions Made'); + lines.push(cont.decisions); + lines.push(''); + lines.push('## Context'); + lines.push(cont.context); + lines.push(''); + lines.push('## Next Action'); + lines.push(cont.nextAction); + + return lines.join('\n'); +} + +// ─── File I/O ────────────────────────────────────────────────────────────── + +/** + * Load a file from disk. Returns content string or null if file doesn't exist. + */ +export async function loadFile(path: string): Promise { + try { + return await fs.readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +/** + * Save content to a file atomically (write to temp, then rename). + * Creates parent directories if needed. + */ +export async function saveFile(path: string, content: string): Promise { + const dir = dirname(path); + await fs.mkdir(dir, { recursive: true }); + + const tmpPath = path + '.tmp'; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, path); +} + +export function parseRequirementCounts(content: string | null): RequirementCounts { + const counts: RequirementCounts = { + active: 0, + validated: 0, + deferred: 0, + outOfScope: 0, + blocked: 0, + total: 0, + }; + + if (!content) return counts; + + const sections = [ + { key: 'active', heading: 'Active' }, + { key: 'validated', heading: 'Validated' }, + { key: 'deferred', heading: 'Deferred' }, + { key: 'outOfScope', heading: 'Out of Scope' }, + ] as const; + + for (const section of sections) { + const text = extractSection(content, section.heading, 2); + if (!text) continue; + const matches = text.match(/^###\s+R\d+\s+—/gm); + counts[section.key] = matches ? matches.length : 0; + } + + const blockedMatches = content.match(/^-\s+Status:\s+blocked\s*$/gim); + counts.blocked = blockedMatches ? blockedMatches.length : 0; + counts.total = counts.active + counts.validated + counts.deferred + counts.outOfScope; + return counts; +} + +// ─── Task Plan Must-Haves Parser ─────────────────────────────────────────── + +/** + * Parse must-have items from a task plan's `## Must-Haves` section. + * Returns structured items with checkbox state. Handles YAML frontmatter, + * all common checkbox variants (`[ ]`, `[x]`, `[X]`), plain bullets (no checkbox), + * and indented variants. Returns empty array when the section is missing or empty. + */ +export function parseTaskPlanMustHaves(content: string): Array<{ text: string; checked: boolean }> { + const [, body] = splitFrontmatter(content); + const sectionText = extractSection(body, 'Must-Haves'); + if (!sectionText) return []; + + const bullets = parseBullets(sectionText); + if (bullets.length === 0) return []; + + return bullets.map(line => { + const cbMatch = line.match(/^\[([xX ])\]\s+(.+)/); + if (cbMatch) { + return { + text: cbMatch[2].trim(), + checked: cbMatch[1].toLowerCase() === 'x', + }; + } + // No checkbox — treat as unchecked with full line as text + return { text: line.trim(), checked: false }; + }); +} + +// ─── Must-Have Summary Matching ──────────────────────────────────────────── + +/** Common short words to exclude from substring matching. */ +const COMMON_WORDS = new Set([ + 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', + 'was', 'one', 'our', 'out', 'has', 'its', 'let', 'say', 'she', 'too', 'use', + 'with', 'have', 'from', 'this', 'that', 'they', 'been', 'each', 'when', 'will', + 'does', 'into', 'also', 'than', 'them', 'then', 'some', 'what', 'only', 'just', + 'more', 'make', 'like', 'made', 'over', 'such', 'take', 'most', 'very', 'must', + 'file', 'test', 'tests', 'task', 'new', 'add', 'added', 'existing', +]); + +/** + * Count how many must-have items are mentioned in a summary. + * + * Matching heuristic per must-have: + * 1. Extract all backtick-enclosed code tokens (e.g. `inspectFoo`). + * If any code token appears case-insensitively in the summary, count as mentioned. + * 2. If no code tokens exist, check if any significant word (≥4 chars, not a common word) + * from the must-have text appears in the summary (case-insensitive). + * + * Returns the count of must-haves that had at least one match. + */ +export function countMustHavesMentionedInSummary( + mustHaves: Array<{ text: string; checked: boolean }>, + summaryContent: string, +): number { + if (!summaryContent || mustHaves.length === 0) return 0; + + const summaryLower = summaryContent.toLowerCase(); + let count = 0; + + for (const mh of mustHaves) { + // Extract backtick-enclosed code tokens + const codeTokens: string[] = []; + const codeRegex = /`([^`]+)`/g; + let match: RegExpExecArray | null; + while ((match = codeRegex.exec(mh.text)) !== null) { + codeTokens.push(match[1]); + } + + if (codeTokens.length > 0) { + // Strategy 1: any code token found in summary (case-insensitive) + const found = codeTokens.some(token => summaryLower.includes(token.toLowerCase())); + if (found) count++; + } else { + // Strategy 2: significant substring matching + // Split into words, keep words ≥4 chars that aren't common + const words = mh.text.replace(/[^\w\s]/g, ' ').split(/\s+/).filter(w => + w.length >= 4 && !COMMON_WORDS.has(w.toLowerCase()) + ); + const found = words.some(word => summaryLower.includes(word.toLowerCase())); + if (found) count++; + } + } + + return count; +} + +// ─── UAT Type Extractor ──────────────────────────────────────────────────── + +/** + * The four UAT classification types recognised by GSD auto-mode. + * `undefined` is returned (not this union) when no type can be determined. + */ +export type UatType = 'artifact-driven' | 'live-runtime' | 'human-experience' | 'mixed'; + +/** + * Extract the UAT type from a UAT file's raw content. + * + * UAT files have no YAML frontmatter — pass raw file content directly. + * Classification is leading-keyword-only: e.g. `mixed (artifact-driven + live-runtime)` → `'mixed'`. + * + * Returns `undefined` when: + * - the `## UAT Type` section is absent + * - no `UAT mode:` bullet is found in the section + * - the value does not start with a recognised keyword + */ +export function extractUatType(content: string): UatType | undefined { + const sectionText = extractSection(content, 'UAT Type'); + if (!sectionText) return undefined; + + const bullets = parseBullets(sectionText); + const modeBullet = bullets.find(b => b.startsWith('UAT mode:')); + if (!modeBullet) return undefined; + + const rawValue = modeBullet.slice('UAT mode:'.length).trim().toLowerCase(); + + if (rawValue.startsWith('artifact-driven')) return 'artifact-driven'; + if (rawValue.startsWith('live-runtime')) return 'live-runtime'; + if (rawValue.startsWith('human-experience')) return 'human-experience'; + if (rawValue.startsWith('mixed')) return 'mixed'; + + return undefined; +} + +/** + * Extract the `depends_on` list from M00x-CONTEXT.md YAML frontmatter. + * Returns [] when: content is null, no frontmatter block, field absent, or field is empty. + * Normalizes each dep ID to uppercase (e.g. 'm001' → 'M001'). + */ +export function parseContextDependsOn(content: string | null): string[] { + if (!content) return []; + const [fmLines] = splitFrontmatter(content); + if (!fmLines) return []; + const fm = parseFrontmatterMap(fmLines); + const raw = fm['depends_on']; + if (!Array.isArray(raw) || raw.length === 0) return []; + return (raw as string[]).map(s => String(s).toUpperCase().trim()).filter(Boolean); +} + +/** + * Inline the prior milestone's SUMMARY.md as context for the current milestone's planning prompt. + * Returns null when: (1) `mid` is the first milestone, (2) prior milestone has no SUMMARY file. + * + * Scans the milestones directory using the same readdirSync + sort + M\d+ match pattern + * as findMilestoneIds in state.ts. + */ +export async function inlinePriorMilestoneSummary(mid: string, base: string): Promise { + const dir = milestonesDir(base); + let sorted: string[]; + try { + sorted = readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => { + const match = d.name.match(/^(M\d+)/); + return match ? match[1] : d.name; + }) + .sort(); + } catch { + return null; + } + const idx = sorted.indexOf(mid); + if (idx <= 0) return null; + const prevMid = sorted[idx - 1]; + const absPath = resolveMilestoneFile(base, prevMid, "SUMMARY"); + const relPath = relMilestoneFile(base, prevMid, "SUMMARY"); + const content = absPath ? await loadFile(absPath) : null; + if (!content) return null; + return `### Prior Milestone Summary\nSource: \`${relPath}\`\n\n${content.trim()}`; +} diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts new file mode 100644 index 000000000..a9821de1c --- /dev/null +++ b/src/resources/extensions/gsd/gitignore.ts @@ -0,0 +1,104 @@ +/** + * GSD .gitignore bootstrapper + * + * Ensures a baseline .gitignore exists with universally-correct patterns. + * Idempotent — only appends entries that are missing. + */ + +import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +/** + * Patterns that are always correct regardless of project type. + * No one ever wants these tracked. + */ +const BASELINE_PATTERNS = [ + // ── GSD runtime (not source artifacts) ── + ".gsd/activity/", + ".gsd/runtime/", + ".gsd/auto.lock", + ".gsd/metrics.json", + ".gsd/STATE.md", + + // ── OS junk ── + ".DS_Store", + "Thumbs.db", + + // ── Editor / IDE ── + "*.swp", + "*.swo", + "*~", + ".idea/", + ".vscode/", + "*.code-workspace", + + // ── Environment / secrets ── + ".env", + ".env.*", + "!.env.example", + + // ── Node / JS / TS ── + "node_modules/", + ".next/", + "dist/", + "build/", + + // ── Python ── + "__pycache__/", + "*.pyc", + ".venv/", + "venv/", + + // ── Rust ── + "target/", + + // ── Go ── + "vendor/", + + // ── Misc build artifacts ── + "*.log", + "coverage/", + ".cache/", + "tmp/", +]; + +/** + * Ensure basePath/.gitignore contains all baseline patterns. + * Creates the file if missing; appends only missing lines if it exists. + * Returns true if the file was created or modified, false if already complete. + */ +export function ensureGitignore(basePath: string): boolean { + const gitignorePath = join(basePath, ".gitignore"); + + let existing = ""; + if (existsSync(gitignorePath)) { + existing = readFileSync(gitignorePath, "utf-8"); + } + + // Parse existing lines (trimmed, ignoring comments and blanks) + const existingLines = new Set( + existing + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")), + ); + + // Find patterns not yet present + const missing = BASELINE_PATTERNS.filter((p) => !existingLines.has(p)); + + if (missing.length === 0) return false; + + // Build the block to append + const block = [ + "", + "# ── GSD baseline (auto-generated) ──", + ...missing, + "", + ].join("\n"); + + // Ensure existing content ends with a newline before appending + const prefix = existing && !existing.endsWith("\n") ? "\n" : ""; + writeFileSync(gitignorePath, existing + prefix + block, "utf-8"); + + return true; +} diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts new file mode 100644 index 000000000..b6439db04 --- /dev/null +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -0,0 +1,800 @@ +/** + * GSD Guided Flow — Smart Entry Wizard + * + * One function: showSmartEntry(). Reads state from disk, shows a contextual + * wizard via showNextAction(), and dispatches through GSD-WORKFLOW.md. + * No execution state, no hooks, no tools — the LLM does the rest. + */ + +import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { showNextAction } from "../shared/next-action-ui.js"; +import { loadFile, parseRoadmap } from "./files.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { deriveState } from "./state.js"; +import { startAuto } from "./auto.js"; +import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js"; +import { + gsdRoot, milestonesDir, resolveMilestoneFile, + resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile, + relMilestoneFile, relSliceFile, relSlicePath, +} from "./paths.js"; +import { join } from "node:path"; +import { readFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { ensureGitignore } from "./gitignore.js"; + +// ─── Auto-start after discuss ───────────────────────────────────────────────── + +/** Stashed context + flag for auto-starting after discuss phase completes */ +let pendingAutoStart: { + ctx: ExtensionCommandContext; + pi: ExtensionAPI; + basePath: string; + milestoneId: string; // the milestone being discussed +} | null = null; + +/** Called from agent_end to check if auto-mode should start after discuss */ +export function checkAutoStartAfterDiscuss(): boolean { + if (!pendingAutoStart) return false; + + const { ctx, pi, basePath, milestoneId } = pendingAutoStart; + + // Don't fire until the discuss phase has actually produced a context file + // for the milestone being discussed. agent_end fires after every LLM turn, + // including the initial "What do you want to build?" response — we need to + // wait for the full conversation to complete and the LLM to write CONTEXT.md. + const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT"); + if (!contextFile) return false; // no context yet — keep waiting + + pendingAutoStart = null; + startAuto(ctx, pi, basePath, false).catch(() => {}); + return true; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type UIContext = ExtensionContext; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Read GSD-WORKFLOW.md and dispatch it to the LLM with a contextual note. + * This is the only way the wizard triggers work — everything else is the LLM's job. + */ +function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"): void { + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + const workflow = readFileSync(workflowPath, "utf-8"); + + pi.sendMessage( + { + customType, + content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${note}`, + display: false, + }, + { triggerTurn: true }, + ); +} + +/** + * Build the discuss-and-plan prompt for a new milestone. + * Used by all three "new milestone" paths (first ever, no active, all complete). + */ +function buildDiscussPrompt(nextId: string, preamble: string, basePath: string): string { + const milestoneDirAbs = join(basePath, ".gsd", "milestones", nextId); + return loadPrompt("discuss", { + milestoneId: nextId, + preamble, + contextAbsPath: join(milestoneDirAbs, `${nextId}-CONTEXT.md`), + roadmapAbsPath: join(milestoneDirAbs, `${nextId}-ROADMAP.md`), + }); +} + +function findMilestoneIds(basePath: string): string[] { + const dir = milestonesDir(basePath); + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => { + const match = d.name.match(/^(M\d+)/); + return match ? match[1] : d.name; + }) + .sort(); + } catch { + return []; + } +} + +// ─── Queue ───────────────────────────────────────────────────────────────────── + +/** + * Queue future milestones via conversational intake. + * + * Safe to run while auto-mode is executing — only writes to future milestone + * directories (which auto-mode won't touch until it reaches them) and appends + * to project.md / queue.md. + * + * The flow: + * 1. Build context about all existing milestones (complete, active, pending) + * 2. Dispatch the queue prompt — LLM discusses with the user, assesses scope + * 3. LLM writes CONTEXT.md files for new milestones (no roadmaps — JIT) + * 4. Auto-mode picks them up naturally when it advances past current work + * + * Root durable artifacts use uppercase names like PROJECT.md and QUEUE.md. + */ +export async function showQueue( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, +): Promise { + // ── Ensure .gsd/ exists ───────────────────────────────────────────── + const gsd = gsdRoot(basePath); + if (!existsSync(gsd)) { + ctx.ui.notify("No GSD project found. Run /gsd to start one first.", "warning"); + return; + } + + const state = await deriveState(basePath); + const milestoneIds = findMilestoneIds(basePath); + + if (milestoneIds.length === 0) { + ctx.ui.notify("No milestones exist yet. Run /gsd to create the first one.", "warning"); + return; + } + + // ── Build existing milestones context for the prompt ──────────────── + const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state); + + // ── Determine next milestone ID ───────────────────────────────────── + const maxNum = milestoneIds.reduce((max, id) => { + const num = parseInt(id.replace(/^M/, ""), 10); + return num > max ? num : max; + }, 0); + const nextId = `M${String(maxNum + 1).padStart(3, "0")}`; + const nextIdPlus1 = `M${String(maxNum + 2).padStart(3, "0")}`; + + // ── Build preamble ────────────────────────────────────────────────── + const activePart = state.activeMilestone + ? `Currently executing: ${state.activeMilestone.id} — ${state.activeMilestone.title} (phase: ${state.phase}).` + : "No milestone currently active."; + + const pendingCount = state.registry.filter(m => m.status === "pending").length; + const completeCount = state.registry.filter(m => m.status === "complete").length; + + const preamble = [ + `Queuing new work onto an existing GSD project.`, + activePart, + `${completeCount} milestone(s) complete, ${pendingCount} pending.`, + `Next available milestone ID: ${nextId}.`, + ].join(" "); + + // ── Dispatch the queue prompt ─────────────────────────────────────── + const prompt = loadPrompt("queue", { + preamble, + nextId, + nextIdPlus1, + existingMilestonesContext: existingContext, + }); + + pi.sendMessage( + { + customType: "gsd-queue", + content: prompt, + display: false, + }, + { triggerTurn: true }, + ); +} + +/** + * Build a context block describing all existing milestones for the queue prompt. + * Gives the LLM enough information to dedup, sequence, and dependency-check. + */ +async function buildExistingMilestonesContext( + basePath: string, + milestoneIds: string[], + state: import("./types.js").GSDState, +): Promise { + const sections: string[] = []; + + // Include PROJECT.md if it exists — it has the milestone sequence and project description + const projectPath = resolveGsdRootFile(basePath, "PROJECT"); + if (existsSync(projectPath)) { + const projectContent = await loadFile(projectPath); + if (projectContent) { + sections.push(`### Project Overview\nSource: \`${relGsdRootFile("PROJECT")}\`\n\n${projectContent.trim()}`); + } + } + + // Include DECISIONS.md if it exists — architectural decisions inform new milestone scoping + const decisionsPath = resolveGsdRootFile(basePath, "DECISIONS"); + if (existsSync(decisionsPath)) { + const decisionsContent = await loadFile(decisionsPath); + if (decisionsContent) { + sections.push(`### Decisions Register\nSource: \`${relGsdRootFile("DECISIONS")}\`\n\n${decisionsContent.trim()}`); + } + } + + // For each milestone, include context and status + for (const mid of milestoneIds) { + const registryEntry = state.registry.find(m => m.id === mid); + const status = registryEntry?.status ?? "unknown"; + const title = registryEntry?.title ?? mid; + + const parts: string[] = []; + parts.push(`### ${mid}: ${title}\n**Status:** ${status}`); + + // Include context file — this is the primary content for understanding scope + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + if (contextFile) { + const content = await loadFile(contextFile); + if (content) { + parts.push(`\n**Context:**\n${content.trim()}`); + } + } + + // For completed milestones, include the summary if it exists + if (status === "complete") { + const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (summaryFile) { + const content = await loadFile(summaryFile); + if (content) { + parts.push(`\n**Summary:**\n${content.trim()}`); + } + } + } + + // For active/pending milestones, include the roadmap if it exists + // (shows what's planned but not yet built) + if (status === "active" || status === "pending") { + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + if (roadmapFile) { + const content = await loadFile(roadmapFile); + if (content) { + parts.push(`\n**Roadmap:**\n${content.trim()}`); + } + } + } + + sections.push(parts.join("\n")); + } + + // Include queue log if it exists — shows what's been queued before + const queuePath = resolveGsdRootFile(basePath, "QUEUE"); + if (existsSync(queuePath)) { + const queueContent = await loadFile(queuePath); + if (queueContent) { + sections.push(`### Previous Queue Entries\nSource: \`${relGsdRootFile("QUEUE")}\`\n\n${queueContent.trim()}`); + } + } + + return sections.join("\n\n---\n\n"); +} + +// ─── Discuss Flow ───────────────────────────────────────────────────────────── + +/** + * Build a rich inlined-context prompt for discussing a specific slice. + * Preloads roadmap, milestone context, research, decisions, and completed + * slice summaries so the agent can ask grounded UX/behaviour questions + * without wasting a turn reading files. + */ +async function buildDiscussSlicePrompt( + mid: string, + sid: string, + sTitle: string, + base: string, +): Promise { + const inlined: string[] = []; + + // Roadmap — always included so the agent sees surrounding slices + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (roadmapContent) { + inlined.push(`### Milestone Roadmap\nSource: \`${roadmapRel}\`\n\n${roadmapContent.trim()}`); + } + + // Milestone context — understanding the full milestone intent + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const contextContent = contextPath ? await loadFile(contextPath) : null; + if (contextContent) { + inlined.push(`### Milestone Context\nSource: \`${contextRel}\`\n\n${contextContent.trim()}`); + } + + // Milestone research — technical grounding + const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); + const researchRel = relMilestoneFile(base, mid, "RESEARCH"); + const researchContent = researchPath ? await loadFile(researchPath) : null; + if (researchContent) { + inlined.push(`### Milestone Research\nSource: \`${researchRel}\`\n\n${researchContent.trim()}`); + } + + // Decisions — architectural context that constrains this slice + const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); + if (existsSync(decisionsPath)) { + const decisionsContent = await loadFile(decisionsPath); + if (decisionsContent) { + inlined.push(`### Decisions Register\nSource: \`${relGsdRootFile("DECISIONS")}\`\n\n${decisionsContent.trim()}`); + } + } + + // Completed slice summaries — what was already built that this slice builds on + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + for (const s of roadmap.slices) { + if (!s.done || s.id === sid) continue; + const summaryPath = resolveSliceFile(base, mid, s.id, "SUMMARY"); + const summaryRel = relSliceFile(base, mid, s.id, "SUMMARY"); + const summaryContent = summaryPath ? await loadFile(summaryPath) : null; + if (summaryContent) { + inlined.push(`### ${s.id} Summary (completed)\nSource: \`${summaryRel}\`\n\n${summaryContent.trim()}`); + } + } + } + + const inlinedContext = inlined.length > 0 + ? `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}` + : `## Inlined Context\n\n_(no context files found yet — go in blind and ask broad questions)_`; + + const sliceDirAbsPath = join(base, ".gsd", "milestones", mid, "slices", sid); + const contextAbsPath = join(sliceDirAbsPath, `${sid}-CONTEXT.md`); + + return loadPrompt("guided-discuss-slice", { + milestoneId: mid, + sliceId: sid, + sliceTitle: sTitle, + inlinedContext, + sliceDirAbsPath, + contextAbsPath, + projectRoot: base, + }); +} + +/** + * /gsd discuss — show a picker of non-done slices and run a slice interview. + * Loops back to the picker after each discussion so the user can chain + * multiple slice interviews in one session. + */ +export async function showDiscuss( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, +): Promise { + // Guard: no .gsd/ project + if (!existsSync(join(basePath, ".gsd"))) { + ctx.ui.notify("No GSD project found. Run /gsd to start one first.", "warning"); + return; + } + + const state = await deriveState(basePath); + + // Guard: no active milestone + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning"); + return; + } + + const mid = state.activeMilestone.id; + const milestoneTitle = state.activeMilestone.title; + + // Guard: no roadmap yet + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) { + ctx.ui.notify("No roadmap yet for this milestone. Run /gsd to plan first.", "warning"); + return; + } + + const roadmap = parseRoadmap(roadmapContent); + const pendingSlices = roadmap.slices.filter(s => !s.done); + + if (pendingSlices.length === 0) { + ctx.ui.notify("All slices are complete — nothing to discuss.", "info"); + return; + } + + // Loop: show picker, dispatch discuss, repeat until "not_yet" + while (true) { + const actions = pendingSlices.map((s, i) => ({ + id: s.id, + label: `${s.id}: ${s.title}`, + description: state.activeSlice?.id === s.id ? "active slice" : "upcoming", + recommended: i === 0, + })); + + const choice = await showNextAction(ctx as any, { + title: "GSD — Discuss a slice", + summary: [ + `${mid}: ${milestoneTitle}`, + "Pick a slice to interview. Context file will be written when done.", + ], + actions, + notYetMessage: "Run /gsd discuss when ready.", + }); + + if (choice === "not_yet") return; + + const chosen = pendingSlices.find(s => s.id === choice); + if (!chosen) return; + + const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath); + dispatchWorkflow(pi, prompt, "gsd-discuss"); + + // Wait for the discuss session to finish, then loop back to the picker + await ctx.waitForIdle(); + } +} + +// ─── Smart Entry Point ──────────────────────────────────────────────────────── + +/** + * The one wizard. Reads state, shows contextual options, dispatches into the workflow doc. + */ +export async function showSmartEntry( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, +): Promise { + + // ── Ensure git repo exists — GSD needs it for branch-per-slice ────── + try { + execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" }); + } catch { + execSync("git init", { cwd: basePath, stdio: "pipe" }); + } + + // ── Ensure .gitignore has baseline patterns ────────────────────────── + ensureGitignore(basePath); + + // ── No GSD project OR no milestone → Create first/next milestone ──── + if (!existsSync(join(basePath, ".gsd"))) { + // Bootstrap .gsd/ silently — the user wants a milestone, not to "init" + const gsd = gsdRoot(basePath); + mkdirSync(join(gsd, "milestones"), { recursive: true }); + try { + execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", { + cwd: basePath, + stdio: "pipe", + }); + } catch { + // nothing to commit — that's fine + } + } + + // Check for crash from previous auto-mode session + const crashLock = readCrashLock(basePath); + if (crashLock) { + clearLock(basePath); + const resume = await showNextAction(ctx as any, { + title: "GSD — Interrupted Session Detected", + summary: [formatCrashInfo(crashLock)], + actions: [ + { id: "resume", label: "Resume with /gsd auto", description: "Pick up where it left off", recommended: true }, + { id: "continue", label: "Continue manually", description: "Open the wizard as normal" }, + ], + }); + if (resume === "resume") { + await startAuto(ctx, pi, basePath, false); + return; + } + } + + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + const milestoneIds = findMilestoneIds(basePath); + const nextId = `M${String(milestoneIds.length + 1).padStart(3, "0")}`; + const isFirst = milestoneIds.length === 0; + + if (isFirst) { + // First ever — skip wizard, just ask directly + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + dispatchWorkflow(pi, buildDiscussPrompt(nextId, + `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, + basePath + )); + } else { + const choice = await showNextAction(ctx as any, { + title: "GSD — Get Stuff Done", + summary: ["No active milestone."], + actions: [ + { + id: "new_milestone", + label: "Create next milestone", + description: "Define what to build next.", + recommended: true, + }, + ], + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "new_milestone") { + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + dispatchWorkflow(pi, buildDiscussPrompt(nextId, + `New milestone ${nextId}.`, + basePath + )); + } + } + return; + } + + const milestoneId = state.activeMilestone.id; + const milestoneTitle = state.activeMilestone.title; + + // ── All milestones complete → New milestone ────────────────────────── + if (state.phase === "complete") { + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId}: ${milestoneTitle}`, + summary: ["All milestones complete."], + actions: [ + { + id: "new_milestone", + label: "Start new milestone", + description: "Define and plan the next milestone.", + recommended: true, + }, + { + id: "status", + label: "View status", + description: "Review what was built.", + }, + ], + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "new_milestone") { + const milestoneIds = findMilestoneIds(basePath); + const nextId = `M${String(milestoneIds.length + 1).padStart(3, "0")}`; + + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + dispatchWorkflow(pi, buildDiscussPrompt(nextId, + `New milestone ${nextId}.`, + basePath + )); + } else if (choice === "status") { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } + return; + } + + // ── No active slice ────────────────────────────────────────────────── + if (!state.activeSlice) { + const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + const hasRoadmap = !!(roadmapFile && await loadFile(roadmapFile)); + + if (!hasRoadmap) { + // No roadmap → discuss or plan + const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + + const actions = [ + { + id: "plan", + label: "Create roadmap", + description: hasContext + ? "Context captured. Decompose into slices with a boundary map." + : "Decompose the milestone into slices with a boundary map.", + recommended: true, + }, + ...(!hasContext ? [{ + id: "discuss", + label: "Discuss first", + description: "Capture decisions on gray areas before planning.", + }] : []), + ]; + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId}: ${milestoneTitle}`, + summary: [hasContext ? "Context captured. Ready to create roadmap." : "New milestone — no roadmap yet."], + actions, + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "plan") { + dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", { + milestoneId, milestoneTitle, + })); + } else if (choice === "discuss") { + dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + milestoneId, milestoneTitle, + })); + } + } else { + // Roadmap exists — either blocked or ready for auto + const actions = [ + { + id: "auto", + label: "Go auto", + description: "Execute everything automatically until milestone complete.", + recommended: true, + }, + { + id: "status", + label: "View status", + description: "See milestone progress and blockers.", + }, + ]; + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId}: ${milestoneTitle}`, + summary: ["Roadmap exists. Ready to execute."], + actions, + notYetMessage: "Run /gsd status for details.", + }); + + if (choice === "auto") { + await startAuto(ctx, pi, basePath, false); + } else if (choice === "status") { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } + } + return; + } + + const sliceId = state.activeSlice.id; + const sliceTitle = state.activeSlice.title; + + // ── Slice needs planning ───────────────────────────────────────────── + if (state.phase === "planning") { + const contextFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTEXT"); + const researchFile = resolveSliceFile(basePath, milestoneId, sliceId, "RESEARCH"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + const actions = [ + { + id: "plan", + label: `Plan ${sliceId}`, + description: `Decompose "${sliceTitle}" into tasks with must-haves.`, + recommended: true, + }, + ...(!hasContext ? [{ + id: "discuss", + label: `Discuss ${sliceId} first`, + description: "Capture context and decisions for this slice.", + }] : []), + ...(!hasResearch ? [{ + id: "research", + label: `Research ${sliceId} first`, + description: "Scout codebase and relevant docs.", + }] : []), + { + id: "status", + label: "View status", + description: "See milestone progress.", + }, + ]; + + const summaryParts = []; + if (hasContext) summaryParts.push("context ✓"); + if (hasResearch) summaryParts.push("research ✓"); + const summaryLine = summaryParts.length > 0 + ? `${sliceId}: ${sliceTitle} (${summaryParts.join(", ")})` + : `${sliceId}: ${sliceTitle} — ready for planning.`; + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`, + summary: [summaryLine], + actions, + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "plan") { + dispatchWorkflow(pi, loadPrompt("guided-plan-slice", { + milestoneId, sliceId, sliceTitle, + })); + } else if (choice === "discuss") { + dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath)); + } else if (choice === "research") { + dispatchWorkflow(pi, loadPrompt("guided-research-slice", { + milestoneId, sliceId, sliceTitle, + })); + } else if (choice === "status") { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } + return; + } + + // ── All tasks done → Complete slice ────────────────────────────────── + if (state.phase === "summarizing") { + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`, + summary: ["All tasks complete. Ready for slice summary."], + actions: [ + { + id: "complete", + label: `Complete ${sliceId}`, + description: "Write slice summary, UAT, mark done, and squash-merge to main.", + recommended: true, + }, + { + id: "status", + label: "View status", + description: "Review tasks before completing.", + }, + ], + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "complete") { + dispatchWorkflow(pi, loadPrompt("guided-complete-slice", { + milestoneId, sliceId, sliceTitle, + })); + } else if (choice === "status") { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } + return; + } + + // ── Active task → Execute ──────────────────────────────────────────── + if (state.activeTask) { + const taskId = state.activeTask.id; + const taskTitle = state.activeTask.title; + + const continueFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE"); + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + const hasInterrupted = !!(continueFile && await loadFile(continueFile)) || + !!(sDir && await loadFile(join(sDir, "continue.md"))); + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`, + summary: [ + hasInterrupted + ? `Resuming: ${taskId} — ${taskTitle}` + : `Next: ${taskId} — ${taskTitle}`, + ], + actions: [ + { + id: "execute", + label: hasInterrupted ? `Resume ${taskId}` : `Execute ${taskId}`, + description: hasInterrupted + ? "Continue from where you left off." + : `Start working on "${taskTitle}".`, + recommended: true, + }, + { + id: "auto", + label: "Go auto", + description: "Execute this and all remaining tasks automatically.", + }, + { + id: "status", + label: "View status", + description: "See slice progress before starting.", + }, + ], + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "auto") { + await startAuto(ctx, pi, basePath, false); + return; + } + + if (choice === "execute") { + if (hasInterrupted) { + dispatchWorkflow(pi, loadPrompt("guided-resume-task", { + milestoneId, sliceId, + })); + } else { + dispatchWorkflow(pi, loadPrompt("guided-execute-task", { + milestoneId, sliceId, taskId, taskTitle, + })); + } + } else if (choice === "status") { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } + return; + } + + // ── Fallback: show status ──────────────────────────────────────────── + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); +} diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts new file mode 100644 index 000000000..c46be19c1 --- /dev/null +++ b/src/resources/extensions/gsd/index.ts @@ -0,0 +1,418 @@ +/** + * GSD Extension — /gsd + * + * One command, one wizard. Reads state from disk, shows contextual options, + * dispatches through GSD-WORKFLOW.md. The LLM does the rest. + * + * Auto-mode: /gsd auto loops fresh sessions until milestone complete. + * + * Commands: + * /gsd — contextual wizard (smart entry point) + * /gsd auto — start auto-mode (fresh session per unit) + * /gsd stop — stop auto-mode gracefully + * /gsd status — progress dashboard + * + * Hooks: + * before_agent_start — inject GSD system context for GSD projects + * agent_end — auto-mode advancement + * session_before_compact — save continue.md OR block during auto + */ + +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; + +import { registerGSDCommand } from "./commands.js"; +import { saveFile, formatContinue, loadFile, parseContinue, parseSummary } from "./files.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { deriveState } from "./state.js"; +import { isAutoActive, isAutoPaused, handleAgentEnd, pauseAuto, getAutoDashboardData } from "./auto.js"; +import { saveActivityLog } from "./activity-log.js"; +import { checkAutoStartAfterDiscuss } from "./guided-flow.js"; +import { GSDDashboardOverlay } from "./dashboard-overlay.js"; +import { + loadEffectiveGSDPreferences, + renderPreferencesForSystemPrompt, + resolveAllSkillReferences, +} from "./preferences.js"; +import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js"; +import { + resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTaskFiles, resolveTasksDir, + relSliceFile, relSlicePath, relTaskFile, + buildSliceFileName, gsdRoot, +} from "./paths.js"; +import { Key } from "@mariozechner/pi-tui"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { Text } from "@mariozechner/pi-tui"; + +// ── ASCII logo ──────────────────────────────────────────────────────────── +const GSD_LOGO_LINES = [ + " ██████╗ ███████╗██████╗ ", + " ██╔════╝ ██╔════╝██╔══██╗", + " ██║ ███╗███████╗██║ ██║", + " ██║ ██║╚════██║██║ ██║", + " ╚██████╔╝███████║██████╔╝", + " ╚═════╝ ╚══════╝╚═════╝ ", +]; + +export default function (pi: ExtensionAPI) { + registerGSDCommand(pi); + + // ── session_start: render branded GSD header ─────────────────────────── + pi.on("session_start", async (_event, ctx) => { + const theme = ctx.ui.theme; + const version = process.env.GSD_VERSION || "0.0.0"; + + const logoText = GSD_LOGO_LINES.map((line) => theme.fg("accent", line)).join("\n"); + const titleLine = ` ${theme.bold("Get Shit Done")} ${theme.fg("dim", `v${version}`)}`; + + const headerContent = `${logoText}\n${titleLine}`; + ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0)); + }); + + // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ──────────────────────── + pi.registerShortcut(Key.ctrlAlt("g"), { + description: "Open GSD dashboard", + handler: async (ctx) => { + // Only show if .gsd/ exists + if (!existsSync(join(process.cwd(), ".gsd"))) { + ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info"); + return; + } + + await ctx.ui.custom( + (tui, theme, _kb, done) => { + return new GSDDashboardOverlay(tui, theme, () => done()); + }, + { + overlay: true, + overlayOptions: { + width: "90%", + minWidth: 80, + maxHeight: "92%", + anchor: "center", + }, + }, + ); + }, + }); + + // ── before_agent_start: inject GSD contract into true system prompt ───── + pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + if (!existsSync(join(process.cwd(), ".gsd"))) return; + + const systemContent = loadPrompt("system"); + const loadedPreferences = loadEffectiveGSDPreferences(); + let preferenceBlock = ""; + if (loadedPreferences) { + const cwd = process.cwd(); + const report = resolveAllSkillReferences(loadedPreferences.preferences, cwd); + preferenceBlock = `\n\n${renderPreferencesForSystemPrompt(loadedPreferences.preferences, report.resolutions)}`; + + // Emit warnings for unresolved skill references + if (report.warnings.length > 0) { + ctx.ui.notify( + `GSD skill preferences: ${report.warnings.length} unresolved skill${report.warnings.length === 1 ? "" : "s"}: ${report.warnings.join(", ")}`, + "warning", + ); + } + } + + // Detect skills installed during this auto-mode session + let newSkillsBlock = ""; + if (hasSkillSnapshot()) { + const newSkills = detectNewSkills(); + if (newSkills.length > 0) { + newSkillsBlock = formatSkillsXml(newSkills); + } + } + + const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); + + return { + systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}`, + ...(injection + ? { + message: { + customType: "gsd-guided-context", + content: injection, + display: false, + }, + } + : {}), + }; + }); + + // ── agent_end: auto-mode advancement or auto-start after discuss ─────────── + pi.on("agent_end", async (event, ctx: ExtensionContext) => { + // If discuss phase just finished, start auto-mode + if (checkAutoStartAfterDiscuss()) return; + + // If auto-mode is already running, advance to next unit + if (!isAutoActive()) return; + + // If the agent was aborted (user pressed Escape), pause auto-mode + // instead of advancing. This preserves the conversation so the user + // can inspect what happened, interact with the agent, or resume. + const lastMsg = event.messages[event.messages.length - 1]; + if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") { + await pauseAuto(ctx, pi); + return; + } + + await handleAgentEnd(ctx, pi); + }); + + // ── session_before_compact ──────────────────────────────────────────────── + pi.on("session_before_compact", async (_event, _ctx: ExtensionContext) => { + // Block compaction during auto-mode — each unit is a fresh session + // Also block during paused state — context is valuable for the user + if (isAutoActive() || isAutoPaused()) { + return { cancel: true }; + } + + const basePath = process.cwd(); + const state = await deriveState(basePath); + + // Only save continue.md if we're actively executing a task + if (!state.activeMilestone || !state.activeSlice || !state.activeTask) return; + if (state.phase !== "executing") return; + + const sDir = resolveSlicePath(basePath, state.activeMilestone.id, state.activeSlice.id); + if (!sDir) return; + + // Check for existing continue file (new naming or legacy) + const existingFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "CONTINUE"); + if (existingFile && await loadFile(existingFile)) return; + const legacyContinue = join(sDir, "continue.md"); + if (await loadFile(legacyContinue)) return; + + const continuePath = join(sDir, buildSliceFileName(state.activeSlice.id, "CONTINUE")); + + const continueData = { + frontmatter: { + milestone: state.activeMilestone.id, + slice: state.activeSlice.id, + task: state.activeTask.id, + step: 0, + totalSteps: 0, + status: "compacted" as const, + savedAt: new Date().toISOString(), + }, + completedWork: `Task ${state.activeTask.id} (${state.activeTask.title}) was in progress when compaction occurred.`, + remainingWork: "Check the task plan for remaining steps.", + decisions: "Check task summary files for prior decisions.", + context: "Session was auto-compacted by Pi. Resume with /gsd.", + nextAction: `Resume task ${state.activeTask.id}: ${state.activeTask.title}.`, + }; + + await saveFile(continuePath, formatContinue(continueData)); + }); + + // ── session_shutdown: save activity log on Ctrl+C / SIGTERM ───────────── + pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => { + if (!isAutoActive() && !isAutoPaused()) return; + + // Save the current session — the lock file stays on disk + // so the next /gsd auto knows it was interrupted + const dash = getAutoDashboardData(); + if (dash.currentUnit) { + saveActivityLog(ctx, dash.basePath, dash.currentUnit.type, dash.currentUnit.id); + } + }); +} + +async function buildGuidedExecuteContextInjection(prompt: string, basePath: string): Promise { + const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+)/i); + if (executeMatch) { + const [, taskId, taskTitle, sliceId, milestoneId] = executeMatch; + return buildTaskExecutionContextInjection(basePath, milestoneId, sliceId, taskId, taskTitle); + } + + const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+)/i); + if (resumeMatch) { + const [, sliceId, milestoneId] = resumeMatch; + const state = await deriveState(basePath); + if ( + state.activeMilestone?.id === milestoneId && + state.activeSlice?.id === sliceId && + state.activeTask + ) { + return buildTaskExecutionContextInjection( + basePath, + milestoneId, + sliceId, + state.activeTask.id, + state.activeTask.title, + ); + } + } + + return null; +} + +async function buildTaskExecutionContextInjection( + basePath: string, + milestoneId: string, + sliceId: string, + taskId: string, + taskTitle: string, +): Promise { + const taskPlanPath = resolveTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); + const taskPlanRelPath = relTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); + const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; + const taskPlanInline = taskPlanContent + ? [ + "## Inlined Task Plan (authoritative local execution contract)", + `Source: \`${taskPlanRelPath}\``, + "", + taskPlanContent.trim(), + ].join("\n") + : [ + "## Inlined Task Plan (authoritative local execution contract)", + `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, + ].join("\n"); + + const slicePlanPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); + const slicePlanRelPath = relSliceFile(basePath, milestoneId, sliceId, "PLAN"); + const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; + const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, slicePlanRelPath); + + const priorTaskLines = await buildCarryForwardLines(basePath, milestoneId, sliceId, taskId); + const resumeSection = await buildResumeSection(basePath, milestoneId, sliceId); + + return [ + "[GSD Guided Execute Context]", + "Use this injected context as startup context for guided task execution. Treat the inlined task plan as the authoritative local execution contract. Use source artifacts to verify details and run checks.", + "", + resumeSection, + "", + "## Carry-Forward Context", + ...priorTaskLines, + "", + taskPlanInline, + "", + slicePlanExcerpt, + "", + "## Backing Source Artifacts", + `- Slice plan: \`${slicePlanRelPath}\``, + `- Task plan source: \`${taskPlanRelPath}\``, + ].join("\n"); +} + +async function buildCarryForwardLines( + basePath: string, + milestoneId: string, + sliceId: string, + taskId: string, +): Promise { + const tDir = resolveTasksDir(basePath, milestoneId, sliceId); + if (!tDir) return ["- No prior task summaries in this slice."]; + + const currentNum = parseInt(taskId.replace(/^T/, ""), 10); + const sRel = relSlicePath(basePath, milestoneId, sliceId); + const summaryFiles = resolveTaskFiles(tDir, "SUMMARY") + .filter((file) => parseInt(file.replace(/^T/, ""), 10) < currentNum) + .sort(); + + if (summaryFiles.length === 0) return ["- No prior task summaries in this slice."]; + + const lines = await Promise.all(summaryFiles.map(async (file) => { + const absPath = join(tDir, file); + const content = await loadFile(absPath); + const relPath = `${sRel}/tasks/${file}`; + if (!content) return `- \`${relPath}\``; + + const summary = parseSummary(content); + const provided = summary.frontmatter.provides.slice(0, 2).join("; "); + const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); + const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); + const diagnostics = extractMarkdownSection(content, "Diagnostics"); + + const parts = [summary.title || relPath]; + if (summary.oneLiner) parts.push(summary.oneLiner); + if (provided) parts.push(`provides: ${provided}`); + if (decisions) parts.push(`decisions: ${decisions}`); + if (patterns) parts.push(`patterns: ${patterns}`); + if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); + + return `- \`${relPath}\` — ${parts.join(" | ")}`; + })); + + return lines; +} + +async function buildResumeSection(basePath: string, milestoneId: string, sliceId: string): Promise { + const continueFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE"); + const legacyDir = resolveSlicePath(basePath, milestoneId, sliceId); + const legacyPath = legacyDir ? join(legacyDir, "continue.md") : null; + const continueContent = continueFile ? await loadFile(continueFile) : null; + const legacyContent = !continueContent && legacyPath ? await loadFile(legacyPath) : null; + const resolvedContent = continueContent ?? legacyContent; + const resolvedRelPath = continueContent + ? relSliceFile(basePath, milestoneId, sliceId, "CONTINUE") + : (legacyPath ? `${relSlicePath(basePath, milestoneId, sliceId)}/continue.md` : null); + + if (!resolvedContent || !resolvedRelPath) { + return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); + } + + const cont = parseContinue(resolvedContent); + const lines = [ + "## Resume State", + `Source: \`${resolvedRelPath}\``, + `- Status: ${cont.frontmatter.status || "in_progress"}`, + ]; + + if (cont.frontmatter.step && cont.frontmatter.totalSteps) { + lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); + } + if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); + if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); + if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); + if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); + + return lines.join("\n"); +} + +function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { + if (!content) { + return [ + "## Slice Plan Excerpt", + `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, + ].join("\n"); + } + + const lines = content.split("\n"); + const goalLine = lines.find((line) => line.startsWith("**Goal:**"))?.trim(); + const demoLine = lines.find((line) => line.startsWith("**Demo:**"))?.trim(); + const verification = extractMarkdownSection(content, "Verification"); + const observability = extractMarkdownSection(content, "Observability / Diagnostics"); + + const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; + if (goalLine) parts.push(goalLine); + if (demoLine) parts.push(demoLine); + if (verification) parts.push("", "### Slice Verification", verification.trim()); + if (observability) parts.push("", "### Slice Observability / Diagnostics", observability.trim()); + return parts.join("\n"); +} + +function extractMarkdownSection(content: string, heading: string): string | null { + const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); + if (!match) return null; + const start = match.index + match[0].length; + const rest = content.slice(start); + const nextHeading = rest.match(/^##\s+/m); + const end = nextHeading?.index ?? rest.length; + return rest.slice(0, end).trim(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts new file mode 100644 index 000000000..63ed65662 --- /dev/null +++ b/src/resources/extensions/gsd/metrics.ts @@ -0,0 +1,372 @@ +/** + * GSD Metrics — Token & Cost Tracking + * + * Accumulates per-unit usage data across auto-mode sessions. + * Data is extracted from session entries before each context wipe, + * written to .gsd/metrics.json, and surfaced in the dashboard. + * + * Data flow: + * 1. Before newSession() wipes context, snapshotUnitMetrics() scans + * session entries for AssistantMessage usage data + * 2. The unit record is appended to the in-memory ledger and flushed to disk + * 3. The dashboard overlay and progress widget read from the in-memory ledger + * 4. On crash recovery or fresh start, the ledger is loaded from disk + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface TokenCounts { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; +} + +export interface UnitMetrics { + type: string; // e.g. "research-milestone", "execute-task" + id: string; // e.g. "M001/S01/T01" + model: string; // model ID used + startedAt: number; // ms timestamp + finishedAt: number; // ms timestamp + tokens: TokenCounts; + cost: number; // total USD cost + toolCalls: number; + assistantMessages: number; + userMessages: number; +} + +export interface MetricsLedger { + version: 1; + projectStartedAt: number; + units: UnitMetrics[]; +} + +// ─── Phase classification ───────────────────────────────────────────────────── + +export type MetricsPhase = "research" | "planning" | "execution" | "completion" | "reassessment"; + +export function classifyUnitPhase(unitType: string): MetricsPhase { + switch (unitType) { + case "research-milestone": + case "research-slice": + return "research"; + case "plan-milestone": + case "plan-slice": + return "planning"; + case "execute-task": + return "execution"; + case "complete-slice": + return "completion"; + case "reassess-roadmap": + return "reassessment"; + default: + return "execution"; + } +} + +// ─── In-memory state ────────────────────────────────────────────────────────── + +let ledger: MetricsLedger | null = null; +let basePath: string = ""; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Initialize the metrics system for a given project. + * Loads existing ledger from disk if present. + */ +export function initMetrics(base: string): void { + basePath = base; + ledger = loadLedger(base); +} + +/** + * Reset in-memory state. Called when auto-mode stops. + */ +export function resetMetrics(): void { + ledger = null; + basePath = ""; +} + +/** + * Snapshot usage metrics from the current session before it's wiped. + * Scans session entries for AssistantMessage usage data. + */ +export function snapshotUnitMetrics( + ctx: ExtensionContext, + unitType: string, + unitId: string, + startedAt: number, + model: string, +): UnitMetrics | null { + if (!ledger) return null; + + const entries = ctx.sessionManager.getEntries(); + if (!entries || entries.length === 0) return null; + + const tokens: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; + let cost = 0; + let toolCalls = 0; + let assistantMessages = 0; + let userMessages = 0; + + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = (entry as any).message; + if (!msg) continue; + + if (msg.role === "assistant") { + assistantMessages++; + if (msg.usage) { + tokens.input += msg.usage.input ?? 0; + tokens.output += msg.usage.output ?? 0; + tokens.cacheRead += msg.usage.cacheRead ?? 0; + tokens.cacheWrite += msg.usage.cacheWrite ?? 0; + tokens.total += msg.usage.totalTokens ?? 0; + if (msg.usage.cost) { + cost += msg.usage.cost.total ?? 0; + } + } + // Count tool calls in this message + if (msg.content && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_call") toolCalls++; + } + } + } else if (msg.role === "user") { + userMessages++; + } + } + + const unit: UnitMetrics = { + type: unitType, + id: unitId, + model, + startedAt, + finishedAt: Date.now(), + tokens, + cost, + toolCalls, + assistantMessages, + userMessages, + }; + + ledger.units.push(unit); + saveLedger(basePath, ledger); + + return unit; +} + +/** + * Get the current ledger (read-only). + */ +export function getLedger(): MetricsLedger | null { + return ledger; +} + +// ─── Aggregation helpers ────────────────────────────────────────────────────── + +export interface PhaseAggregate { + phase: MetricsPhase; + units: number; + tokens: TokenCounts; + cost: number; + duration: number; // ms +} + +export interface SliceAggregate { + sliceId: string; + units: number; + tokens: TokenCounts; + cost: number; + duration: number; +} + +export interface ModelAggregate { + model: string; + units: number; + tokens: TokenCounts; + cost: number; +} + +export interface ProjectTotals { + units: number; + tokens: TokenCounts; + cost: number; + duration: number; + toolCalls: number; + assistantMessages: number; + userMessages: number; +} + +function emptyTokens(): TokenCounts { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; +} + +function addTokens(a: TokenCounts, b: TokenCounts): TokenCounts { + return { + input: a.input + b.input, + output: a.output + b.output, + cacheRead: a.cacheRead + b.cacheRead, + cacheWrite: a.cacheWrite + b.cacheWrite, + total: a.total + b.total, + }; +} + +export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] { + const map = new Map(); + for (const u of units) { + const phase = classifyUnitPhase(u.type); + let agg = map.get(phase); + if (!agg) { + agg = { phase, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 }; + map.set(phase, agg); + } + agg.units++; + agg.tokens = addTokens(agg.tokens, u.tokens); + agg.cost += u.cost; + agg.duration += u.finishedAt - u.startedAt; + } + // Return in a stable order + const order: MetricsPhase[] = ["research", "planning", "execution", "completion", "reassessment"]; + return order.map(p => map.get(p)).filter((a): a is PhaseAggregate => !!a); +} + +export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] { + const map = new Map(); + for (const u of units) { + const parts = u.id.split("/"); + // Slice ID is parts[0]/parts[1] if it exists, else parts[0] + const sliceId = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0]; + let agg = map.get(sliceId); + if (!agg) { + agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 }; + map.set(sliceId, agg); + } + agg.units++; + agg.tokens = addTokens(agg.tokens, u.tokens); + agg.cost += u.cost; + agg.duration += u.finishedAt - u.startedAt; + } + return Array.from(map.values()).sort((a, b) => a.sliceId.localeCompare(b.sliceId)); +} + +export function aggregateByModel(units: UnitMetrics[]): ModelAggregate[] { + const map = new Map(); + for (const u of units) { + let agg = map.get(u.model); + if (!agg) { + agg = { model: u.model, units: 0, tokens: emptyTokens(), cost: 0 }; + map.set(u.model, agg); + } + agg.units++; + agg.tokens = addTokens(agg.tokens, u.tokens); + agg.cost += u.cost; + } + return Array.from(map.values()).sort((a, b) => b.cost - a.cost); +} + +export function getProjectTotals(units: UnitMetrics[]): ProjectTotals { + const totals: ProjectTotals = { + units: units.length, + tokens: emptyTokens(), + cost: 0, + duration: 0, + toolCalls: 0, + assistantMessages: 0, + userMessages: 0, + }; + for (const u of units) { + totals.tokens = addTokens(totals.tokens, u.tokens); + totals.cost += u.cost; + totals.duration += u.finishedAt - u.startedAt; + totals.toolCalls += u.toolCalls; + totals.assistantMessages += u.assistantMessages; + totals.userMessages += u.userMessages; + } + return totals; +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +export function formatCost(cost: number): string { + if (cost < 0.01) return `$${cost.toFixed(4)}`; + if (cost < 1) return `$${cost.toFixed(3)}`; + return `$${cost.toFixed(2)}`; +} + +/** + * Compute a projected remaining cost based on completed slice averages. + * + * Filters to slice-level entries (sliceId contains "/") to exclude bare milestone + * aggregates from the average. Returns [] when fewer than 2 slice-level entries + * exist (insufficient data for a reliable projection). + * + * If `budgetCeiling` is provided and `totalCost >= budgetCeiling`, a warning line + * is appended to the result. + */ +export function formatCostProjection( + completedSlices: SliceAggregate[], + remainingCount: number, + budgetCeiling?: number, +): string[] { + const sliceLevel = completedSlices.filter(s => s.sliceId.includes("/")); + if (sliceLevel.length < 2) return []; + + const totalCost = sliceLevel.reduce((sum, s) => sum + s.cost, 0); + const avgCost = totalCost / sliceLevel.length; + const projected = avgCost * remainingCount; + + const projLine = `Projected remaining: ${formatCost(projected)} (${formatCost(avgCost)}/slice avg × ${remainingCount} remaining)`; + const result: string[] = [projLine]; + + if (budgetCeiling !== undefined && totalCost >= budgetCeiling) { + result.push(`Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)})`); + } + + return result; +} + +export function formatTokenCount(count: number): string { + if (count < 1000) return `${count}`; + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`; + return `${(count / 1_000_000).toFixed(2)}M`; +} + +// ─── Disk I/O ───────────────────────────────────────────────────────────────── + +function metricsPath(base: string): string { + return join(gsdRoot(base), "metrics.json"); +} + +function loadLedger(base: string): MetricsLedger { + try { + const raw = readFileSync(metricsPath(base), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed.version === 1 && Array.isArray(parsed.units)) { + return parsed as MetricsLedger; + } + } catch { + // File doesn't exist or is corrupt — start fresh + } + return { + version: 1, + projectStartedAt: Date.now(), + units: [], + }; +} + +function saveLedger(base: string, data: MetricsLedger): void { + try { + mkdirSync(gsdRoot(base), { recursive: true }); + writeFileSync(metricsPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8"); + } catch { + // Don't let metrics failures break auto-mode + } +} diff --git a/src/resources/extensions/gsd/observability-validator.ts b/src/resources/extensions/gsd/observability-validator.ts new file mode 100644 index 000000000..411cd89b8 --- /dev/null +++ b/src/resources/extensions/gsd/observability-validator.ts @@ -0,0 +1,408 @@ +import { loadFile } from "./files.js"; +import { resolveSliceFile, resolveTaskFile, resolveTasksDir, resolveTaskFiles } from "./paths.js"; + +export interface ValidationIssue { + severity: "info" | "warning" | "error"; + scope: "slice-plan" | "task-plan" | "task-summary" | "slice-summary"; + file: string; + ruleId: string; + message: string; + suggestion?: string; +} + +function getSection(content: string, heading: string, level: number = 2): string | null { + const prefix = "#".repeat(level) + " "; + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`^${prefix}${escaped}\\s*$`, "m"); + const match = regex.exec(content); + if (!match) return null; + + const start = match.index + match[0].length; + const rest = content.slice(start); + const nextHeading = rest.match(new RegExp(`^#{1,${level}} `, "m")); + const end = nextHeading ? nextHeading.index! : rest.length; + return rest.slice(0, end).trim(); +} + +function getFrontmatter(content: string): string | null { + const trimmed = content.trimStart(); + if (!trimmed.startsWith("---")) return null; + const afterFirst = trimmed.indexOf("\n"); + if (afterFirst === -1) return null; + const rest = trimmed.slice(afterFirst + 1); + const endIdx = rest.indexOf("\n---"); + if (endIdx === -1) return null; + return rest.slice(0, endIdx); +} + +function hasFrontmatterKey(content: string, key: string): boolean { + const fm = getFrontmatter(content); + if (!fm) return false; + return new RegExp(`^${key}:`, "m").test(fm); +} + +function normalizeMeaningfulLines(text: string): string[] { + return text + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(line => !line.startsWith("")) + .filter(line => !/^[-*]\s*\{\{.+\}\}$/.test(line)) + .filter(line => !/^\{\{.+\}\}$/.test(line)); +} + +function sectionLooksPlaceholderOnly(text: string | null): boolean { + if (!text) return true; + const lines = normalizeMeaningfulLines(text) + .map(line => line.replace(/^[-*]\s+/, "").trim()) + .filter(line => line.length > 0); + + if (lines.length === 0) return true; + + return lines.every(line => { + const lower = line.toLowerCase(); + return lower === "none" || + lower.endsWith(": none") || + lower.includes("{{") || + lower.includes("}}") || + lower.startsWith("required for non-trivial") || + lower.startsWith("describe how a future agent") || + lower.startsWith("prefer:") || + lower.startsWith("keep this section concise"); + }); +} + +function textSuggestsObservabilityRelevant(content: string): boolean { + const lower = content.toLowerCase(); + const needles = [ + " api", "route", "server", "worker", "queue", "job", "sync", "import", + "webhook", "auth", "db", "database", "migration", "cache", "background", + "polling", "realtime", "socket", "stateful", "integration", "ui", "form", + "submit", "status", "service", "pipeline", "health endpoint", "error path" + ]; + return needles.some(needle => lower.includes(needle)); +} + +function verificationMentionsDiagnostics(section: string | null): boolean { + if (!section) return false; + const lower = section.toLowerCase(); + const needles = [ + "error", "failure", "diagnostic", "status", "health", "inspect", "log", + "network", "console", "retry", "last error", "correlation", "readiness" + ]; + return needles.some(needle => lower.includes(needle)); +} + +export function validateSlicePlanContent(file: string, content: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + // ── Plan quality rules (always run, not gated by runtime relevance) ── + + const tasksSection = getSection(content, "Tasks", 2); + if (tasksSection) { + const lines = tasksSection.split("\n"); + const taskLinePattern = /^- \[[ x]\] \*\*T\d+:/; + const taskLineIndices: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (taskLinePattern.test(lines[i])) taskLineIndices.push(i); + } + + for (let t = 0; t < taskLineIndices.length; t++) { + const start = taskLineIndices[t]; + const end = t + 1 < taskLineIndices.length ? taskLineIndices[t + 1] : lines.length; + // Check lines between this task header and the next (or section end) + const bodyLines = lines.slice(start + 1, end); + const meaningful = bodyLines.filter(l => l.trim().length > 0); + if (meaningful.length === 0) { + issues.push({ + severity: "warning", + scope: "slice-plan", + file, + ruleId: "empty_task_entry", + message: "Inline task entry has no description content beneath the checkbox line.", + suggestion: "Add at least a Why/Files/Do/Verify summary so the task is self-describing.", + }); + } + } + } + + // ── Observability rules (gated by runtime relevance) ── + + const relevant = textSuggestsObservabilityRelevant(content); + if (!relevant) return issues; + + const obs = getSection(content, "Observability / Diagnostics", 2); + const verification = getSection(content, "Verification", 2); + + if (!obs) { + issues.push({ + severity: "warning", + scope: "slice-plan", + file, + ruleId: "missing_observability_section", + message: "Slice plan appears non-trivial but is missing `## Observability / Diagnostics`.", + suggestion: "Add runtime signals, inspection surfaces, failure visibility, and redaction constraints.", + }); + } else if (sectionLooksPlaceholderOnly(obs)) { + issues.push({ + severity: "warning", + scope: "slice-plan", + file, + ruleId: "observability_section_placeholder_only", + message: "Slice plan has `## Observability / Diagnostics` but it still looks like placeholder text.", + suggestion: "Replace placeholders with concrete signals and inspection surfaces a future agent should trust.", + }); + } + + if (!verificationMentionsDiagnostics(verification)) { + issues.push({ + severity: "warning", + scope: "slice-plan", + file, + ruleId: "verification_missing_diagnostic_check", + message: "Slice verification does not appear to include any diagnostic or failure-path check.", + suggestion: "Add at least one verification step for inspectable failure state, structured error output, status surface, or equivalent.", + }); + } + + return issues; +} + +export function validateTaskPlanContent(file: string, content: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + // ── Plan quality rules (always run, not gated by runtime relevance) ── + + // Rule: empty or missing Steps section + const stepsSection = getSection(content, "Steps", 2); + if (stepsSection === null || sectionLooksPlaceholderOnly(stepsSection)) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "empty_steps_section", + message: "Task plan has an empty or missing `## Steps` section.", + suggestion: "Add concrete numbered implementation steps so execution has a clear sequence.", + }); + } + + // Rule: placeholder-only Verification section + const verificationSection = getSection(content, "Verification", 2); + if (verificationSection !== null && sectionLooksPlaceholderOnly(verificationSection)) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "placeholder_verification", + message: "Task plan has `## Verification` but it still looks like placeholder text.", + suggestion: "Replace placeholders with concrete verification commands, test runs, or observable checks.", + }); + } + + // Rule: scope estimate thresholds + const fm = getFrontmatter(content); + if (fm) { + const stepsMatch = fm.match(/^estimated_steps:\s*(\d+)/m); + const filesMatch = fm.match(/^estimated_files:\s*(\d+)/m); + + if (stepsMatch) { + const estimatedSteps = parseInt(stepsMatch[1], 10); + if (estimatedSteps >= 10) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "scope_estimate_steps_high", + message: `Task plan estimates ${estimatedSteps} steps (threshold: 10). Consider splitting into smaller tasks.`, + suggestion: "Break the task into sub-tasks or reduce scope so each task stays focused and completable in one pass.", + }); + } + } + + if (filesMatch) { + const estimatedFiles = parseInt(filesMatch[1], 10); + if (estimatedFiles >= 12) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "scope_estimate_files_high", + message: `Task plan estimates ${estimatedFiles} files (threshold: 12). Consider splitting into smaller tasks.`, + suggestion: "Break the task into sub-tasks or reduce scope to keep the change footprint manageable.", + }); + } + } + } + + // ── Observability rules (gated by runtime relevance) ── + + const relevant = textSuggestsObservabilityRelevant(content); + if (!relevant) return issues; + + const obs = getSection(content, "Observability Impact", 2); + if (!obs) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "missing_observability_impact", + message: "Task plan appears runtime-relevant but is missing `## Observability Impact`.", + suggestion: "Explain what signals change, how a future agent inspects this task, and what failure state becomes visible.", + }); + } else if (sectionLooksPlaceholderOnly(obs)) { + issues.push({ + severity: "warning", + scope: "task-plan", + file, + ruleId: "observability_impact_placeholder_only", + message: "Task plan has `## Observability Impact` but it still looks empty or placeholder-only.", + suggestion: "Fill in concrete inspection surfaces or explicitly justify why observability is not applicable.", + }); + } + + return issues; +} + +export function validateTaskSummaryContent(file: string, content: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (!hasFrontmatterKey(content, "observability_surfaces")) { + issues.push({ + severity: "warning", + scope: "task-summary", + file, + ruleId: "missing_observability_frontmatter", + message: "Task summary is missing `observability_surfaces` in frontmatter.", + suggestion: "List the durable status/log/error surfaces a future agent should use.", + }); + } + + const diagnostics = getSection(content, "Diagnostics", 2); + if (!diagnostics) { + issues.push({ + severity: "warning", + scope: "task-summary", + file, + ruleId: "missing_diagnostics_section", + message: "Task summary is missing `## Diagnostics`.", + suggestion: "Document how to inspect what this task built later.", + }); + } else if (sectionLooksPlaceholderOnly(diagnostics)) { + issues.push({ + severity: "warning", + scope: "task-summary", + file, + ruleId: "diagnostics_placeholder_only", + message: "Task summary diagnostics section still looks like placeholder text.", + suggestion: "Replace placeholders with concrete commands, endpoints, logs, error shapes, or failure artifacts.", + }); + } + + return issues; +} + +export function validateSliceSummaryContent(file: string, content: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (!hasFrontmatterKey(content, "observability_surfaces")) { + issues.push({ + severity: "warning", + scope: "slice-summary", + file, + ruleId: "missing_observability_frontmatter", + message: "Slice summary is missing `observability_surfaces` in frontmatter.", + suggestion: "List the authoritative diagnostics and durable inspection surfaces for this slice.", + }); + } + + const diagnostics = getSection(content, "Authoritative diagnostics", 3); + if (!diagnostics) { + issues.push({ + severity: "warning", + scope: "slice-summary", + file, + ruleId: "missing_authoritative_diagnostics", + message: "Slice summary is missing `### Authoritative diagnostics` in Forward Intelligence.", + suggestion: "Tell future agents where to look first and why that signal is trustworthy.", + }); + } else if (sectionLooksPlaceholderOnly(diagnostics)) { + issues.push({ + severity: "warning", + scope: "slice-summary", + file, + ruleId: "authoritative_diagnostics_placeholder_only", + message: "Slice summary includes authoritative diagnostics but it still looks like placeholder text.", + suggestion: "Replace placeholders with the real first-stop diagnostic surface for this slice.", + }); + } + + return issues; +} + +export async function validatePlanBoundary(basePath: string, milestoneId: string, sliceId: string): Promise { + const issues: ValidationIssue[] = []; + const slicePlan = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); + if (slicePlan) { + const content = await loadFile(slicePlan); + if (content) issues.push(...validateSlicePlanContent(slicePlan, content)); + } + + const tasksDir = resolveTasksDir(basePath, milestoneId, sliceId); + const taskPlans = tasksDir ? resolveTaskFiles(tasksDir, "PLAN") : []; + for (const file of taskPlans) { + const taskId = file.split("-")[0]; + const taskPlan = resolveTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); + if (!taskPlan) continue; + const content = await loadFile(taskPlan); + if (content) issues.push(...validateTaskPlanContent(taskPlan, content)); + } + + return issues; +} + +export async function validateExecuteBoundary(basePath: string, milestoneId: string, sliceId: string, taskId: string): Promise { + const issues: ValidationIssue[] = []; + const slicePlan = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); + if (slicePlan) { + const content = await loadFile(slicePlan); + if (content) issues.push(...validateSlicePlanContent(slicePlan, content)); + } + + const taskPlan = resolveTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); + if (taskPlan) { + const content = await loadFile(taskPlan); + if (content) issues.push(...validateTaskPlanContent(taskPlan, content)); + } + + return issues; +} + +export async function validateCompleteBoundary(basePath: string, milestoneId: string, sliceId: string): Promise { + const issues: ValidationIssue[] = []; + const tasksDir = resolveTasksDir(basePath, milestoneId, sliceId); + const taskSummaries = tasksDir ? resolveTaskFiles(tasksDir, "SUMMARY") : []; + for (const file of taskSummaries) { + const taskId = file.split("-")[0]; + const taskSummary = resolveTaskFile(basePath, milestoneId, sliceId, taskId, "SUMMARY"); + if (!taskSummary) continue; + const content = await loadFile(taskSummary); + if (content) issues.push(...validateTaskSummaryContent(taskSummary, content)); + } + + const sliceSummary = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY"); + if (sliceSummary) { + const content = await loadFile(sliceSummary); + if (content) issues.push(...validateSliceSummaryContent(sliceSummary, content)); + } + + return issues; +} + +export function formatValidationIssues(issues: ValidationIssue[], limit: number = 4): string { + if (issues.length === 0) return ""; + const lines = issues.slice(0, limit).map(issue => { + const fileName = issue.file.split("/").pop() || issue.file; + return `- ${fileName}: ${issue.message}`; + }); + if (issues.length > limit) lines.push(`- ...and ${issues.length - limit} more`); + return lines.join("\n"); +} diff --git a/src/resources/extensions/gsd/package.json b/src/resources/extensions/gsd/package.json new file mode 100644 index 000000000..761cf6f77 --- /dev/null +++ b/src/resources/extensions/gsd/package.json @@ -0,0 +1,11 @@ +{ + "name": "pi-extension-gsd", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts new file mode 100644 index 000000000..c41ee596e --- /dev/null +++ b/src/resources/extensions/gsd/paths.ts @@ -0,0 +1,308 @@ +/** + * GSD Paths — ID-based path resolution + * + * Directories use bare IDs: M001/, S01/, etc. + * Files use ID-SUFFIX: M001-ROADMAP.md, S01-PLAN.md, T01-PLAN.md + * + * Resolvers still handle legacy descriptor-suffixed names + * (e.g. M001-FLIGHT-SIMULATOR/, T03-INSTALL-PACKAGES-PLAN.md) + * via prefix matching, so existing projects work without migration. + */ + +import { readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Name Builders ───────────────────────────────────────────────────────── + +/** + * Build a directory name from an ID. + * ("M001") → "M001" + */ +export function buildDirName(id: string): string { + return id; +} + +/** + * Build a milestone-level file name. + * ("M001", "CONTEXT") → "M001-CONTEXT.md" + */ +export function buildMilestoneFileName(milestoneId: string, suffix: string): string { + return `${milestoneId}-${suffix}.md`; +} + +/** + * Build a slice-level file name. + * ("S01", "PLAN") → "S01-PLAN.md" + */ +export function buildSliceFileName(sliceId: string, suffix: string): string { + return `${sliceId}-${suffix}.md`; +} + +/** + * Build a task file name. + * ("T03", "PLAN") → "T03-PLAN.md" + * ("T03", "SUMMARY") → "T03-SUMMARY.md" + */ +export function buildTaskFileName(taskId: string, suffix: string): string { + return `${taskId}-${suffix}.md`; +} + +// ─── Resolvers ───────────────────────────────────────────────────────────── + +/** + * Find a directory entry by ID prefix within a parent directory. + * Exact match first (M001), then prefix match (M001-SOMETHING) for + * backward compatibility with legacy descriptor directories. + * Returns the full directory name or null. + */ +export function resolveDir(parentDir: string, idPrefix: string): string | null { + if (!existsSync(parentDir)) return null; + try { + const entries = readdirSync(parentDir, { withFileTypes: true }); + // Exact match first (current convention: bare ID) + const exact = entries.find(e => e.isDirectory() && e.name === idPrefix); + if (exact) return exact.name; + // Prefix match for legacy descriptor dirs: M001-SOMETHING + const prefixed = entries.find( + e => e.isDirectory() && e.name.startsWith(idPrefix + "-") + ); + return prefixed ? prefixed.name : null; + } catch { + return null; + } +} + +/** + * Find a file by ID prefix and suffix within a directory. + * Checks in order: + * 1. Direct: ID-SUFFIX.md (e.g. M001-ROADMAP.md, T03-PLAN.md) + * 2. Legacy descriptor: ID-DESCRIPTOR-SUFFIX.md (e.g. T03-INSTALL-PACKAGES-PLAN.md) + * 3. Legacy bare: suffix.md (e.g. roadmap.md) + */ +export function resolveFile(dir: string, idPrefix: string, suffix: string): string | null { + if (!existsSync(dir)) return null; + const target = `${idPrefix}-${suffix}.md`.toUpperCase(); + try { + const entries = readdirSync(dir); + // Direct match: ID-SUFFIX.md + const direct = entries.find(e => e.toUpperCase() === target); + if (direct) return direct; + // Legacy pattern match: ID-DESCRIPTOR-SUFFIX.md + const pattern = new RegExp( + `^${idPrefix}-.*-${suffix}\\.md$`, "i" + ); + const match = entries.find(e => pattern.test(e)); + if (match) return match; + // Legacy fallback: suffix.md + const legacy = entries.find(e => e.toLowerCase() === `${suffix.toLowerCase()}.md`); + if (legacy) return legacy; + return null; + } catch { + return null; + } +} + +/** + * Find all task files matching a pattern in a tasks directory. + * Returns sorted file names matching T##-SUFFIX.md or legacy T##-*-SUFFIX.md + */ +export function resolveTaskFiles(tasksDir: string, suffix: string): string[] { + if (!existsSync(tasksDir)) return []; + try { + // Current convention: T01-PLAN.md + const currentPattern = new RegExp(`^T\\d+-${suffix}\\.md$`, "i"); + // Legacy convention: T01-INSTALL-PACKAGES-PLAN.md + const legacyPattern = new RegExp(`^T\\d+-.*-${suffix}\\.md$`, "i"); + return readdirSync(tasksDir) + .filter(f => currentPattern.test(f) || legacyPattern.test(f)) + .sort(); + } catch { + return []; + } +} + +// ─── Full Path Builders ──────────────────────────────────────────────────── + +export const GSD_ROOT_FILES = { + PROJECT: "PROJECT.md", + DECISIONS: "DECISIONS.md", + QUEUE: "QUEUE.md", + STATE: "STATE.md", + REQUIREMENTS: "REQUIREMENTS.md", +} as const; + +export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; + +const LEGACY_GSD_ROOT_FILES: Record = { + PROJECT: "project.md", + DECISIONS: "decisions.md", + QUEUE: "queue.md", + STATE: "state.md", + REQUIREMENTS: "requirements.md", +}; + +export function gsdRoot(basePath: string): string { + return join(basePath, ".gsd"); +} + +export function milestonesDir(basePath: string): string { + return join(gsdRoot(basePath), "milestones"); +} + +export function resolveGsdRootFile(basePath: string, key: GSDRootFileKey): string { + const root = gsdRoot(basePath); + const canonical = join(root, GSD_ROOT_FILES[key]); + if (existsSync(canonical)) return canonical; + const legacy = join(root, LEGACY_GSD_ROOT_FILES[key]); + if (existsSync(legacy)) return legacy; + return canonical; +} + +export function relGsdRootFile(key: GSDRootFileKey): string { + return `.gsd/${GSD_ROOT_FILES[key]}`; +} + +/** + * Resolve the full path to a milestone directory. + * Returns null if the milestone doesn't exist. + */ +export function resolveMilestonePath(basePath: string, milestoneId: string): string | null { + const dir = resolveDir(milestonesDir(basePath), milestoneId); + return dir ? join(milestonesDir(basePath), dir) : null; +} + +/** + * Resolve the full path to a milestone file (e.g. ROADMAP, CONTEXT, RESEARCH). + */ +export function resolveMilestoneFile( + basePath: string, milestoneId: string, suffix: string +): string | null { + const mDir = resolveMilestonePath(basePath, milestoneId); + if (!mDir) return null; + const file = resolveFile(mDir, milestoneId, suffix); + return file ? join(mDir, file) : null; +} + +/** + * Resolve the full path to a slice directory within a milestone. + */ +export function resolveSlicePath( + basePath: string, milestoneId: string, sliceId: string +): string | null { + const mDir = resolveMilestonePath(basePath, milestoneId); + if (!mDir) return null; + const slicesDir = join(mDir, "slices"); + const dir = resolveDir(slicesDir, sliceId); + return dir ? join(slicesDir, dir) : null; +} + +/** + * Resolve the full path to a slice file (e.g. PLAN, RESEARCH, CONTEXT, SUMMARY). + */ +export function resolveSliceFile( + basePath: string, milestoneId: string, sliceId: string, suffix: string +): string | null { + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + if (!sDir) return null; + const file = resolveFile(sDir, sliceId, suffix); + return file ? join(sDir, file) : null; +} + +/** + * Resolve the tasks directory within a slice. + */ +export function resolveTasksDir( + basePath: string, milestoneId: string, sliceId: string +): string | null { + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + if (!sDir) return null; + const tDir = join(sDir, "tasks"); + return existsSync(tDir) ? tDir : null; +} + +/** + * Resolve a specific task file. + */ +export function resolveTaskFile( + basePath: string, milestoneId: string, sliceId: string, + taskId: string, suffix: string +): string | null { + const tDir = resolveTasksDir(basePath, milestoneId, sliceId); + if (!tDir) return null; + const file = resolveFile(tDir, taskId, suffix); + return file ? join(tDir, file) : null; +} + +// ─── Relative Path Builders (for prompts — .gsd/milestones/...) ──────────── + +/** + * Build relative .gsd/ path to a milestone directory. + * Uses the actual directory name on disk if it exists, otherwise bare ID. + */ +export function relMilestonePath(basePath: string, milestoneId: string): string { + const dir = resolveDir(milestonesDir(basePath), milestoneId); + if (dir) return `.gsd/milestones/${dir}`; + return `.gsd/milestones/${milestoneId}`; +} + +/** + * Build relative .gsd/ path to a milestone file. + */ +export function relMilestoneFile( + basePath: string, milestoneId: string, suffix: string +): string { + const mRel = relMilestonePath(basePath, milestoneId); + const mDir = resolveMilestonePath(basePath, milestoneId); + if (mDir) { + const file = resolveFile(mDir, milestoneId, suffix); + if (file) return `${mRel}/${file}`; + } + return `${mRel}/${buildMilestoneFileName(milestoneId, suffix)}`; +} + +/** + * Build relative .gsd/ path to a slice directory. + */ +export function relSlicePath( + basePath: string, milestoneId: string, sliceId: string +): string { + const mRel = relMilestonePath(basePath, milestoneId); + const mDir = resolveMilestonePath(basePath, milestoneId); + if (mDir) { + const slicesDir = join(mDir, "slices"); + const dir = resolveDir(slicesDir, sliceId); + if (dir) return `${mRel}/slices/${dir}`; + } + return `${mRel}/slices/${sliceId}`; +} + +/** + * Build relative .gsd/ path to a slice file. + */ +export function relSliceFile( + basePath: string, milestoneId: string, sliceId: string, suffix: string +): string { + const sRel = relSlicePath(basePath, milestoneId, sliceId); + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + if (sDir) { + const file = resolveFile(sDir, sliceId, suffix); + if (file) return `${sRel}/${file}`; + } + return `${sRel}/${buildSliceFileName(sliceId, suffix)}`; +} + +/** + * Build relative .gsd/ path to a task file. + */ +export function relTaskFile( + basePath: string, milestoneId: string, sliceId: string, + taskId: string, suffix: string +): string { + const sRel = relSlicePath(basePath, milestoneId, sliceId); + const tDir = resolveTasksDir(basePath, milestoneId, sliceId); + if (tDir) { + const file = resolveFile(tDir, taskId, suffix); + if (file) return `${sRel}/tasks/${file}`; + } + return `${sRel}/tasks/${buildTaskFileName(taskId, suffix)}`; +} diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts new file mode 100644 index 000000000..751983baf --- /dev/null +++ b/src/resources/extensions/gsd/preferences.ts @@ -0,0 +1,600 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { getAgentDir } from "@mariozechner/pi-coding-agent"; + +const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); +const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md"); +const PROJECT_PREFERENCES_PATH = join(process.cwd(), ".gsd", "preferences.md"); +const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]); + +export interface GSDSkillRule { + when: string; + use?: string[]; + prefer?: string[]; + avoid?: string[]; +} + +export interface GSDModelConfig { + research?: string; // e.g. "claude-sonnet-4-6" + planning?: string; // e.g. "claude-opus-4-6" + execution?: string; // e.g. "claude-sonnet-4-6" + completion?: string; // e.g. "claude-sonnet-4-6" +} + +export type SkillDiscoveryMode = "auto" | "suggest" | "off"; + +export interface AutoSupervisorConfig { + model?: string; + soft_timeout_minutes?: number; + idle_timeout_minutes?: number; + hard_timeout_minutes?: number; +} + +export interface GSDPreferences { + version?: number; + always_use_skills?: string[]; + prefer_skills?: string[]; + avoid_skills?: string[]; + skill_rules?: GSDSkillRule[]; + custom_instructions?: string[]; + models?: GSDModelConfig; + skill_discovery?: SkillDiscoveryMode; + auto_supervisor?: AutoSupervisorConfig; + uat_dispatch?: boolean; + budget_ceiling?: number; +} + +export interface LoadedGSDPreferences { + path: string; + scope: "global" | "project"; + preferences: GSDPreferences; +} + +export function getGlobalGSDPreferencesPath(): string { + return GLOBAL_PREFERENCES_PATH; +} + +export function getLegacyGlobalGSDPreferencesPath(): string { + return LEGACY_GLOBAL_PREFERENCES_PATH; +} + +export function getProjectGSDPreferencesPath(): string { + return PROJECT_PREFERENCES_PATH; +} + +export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null { + return loadPreferencesFile(GLOBAL_PREFERENCES_PATH, "global") + ?? loadPreferencesFile(LEGACY_GLOBAL_PREFERENCES_PATH, "global"); +} + +export function loadProjectGSDPreferences(): LoadedGSDPreferences | null { + return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project"); +} + +export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null { + const globalPreferences = loadGlobalGSDPreferences(); + const projectPreferences = loadProjectGSDPreferences(); + + if (!globalPreferences && !projectPreferences) return null; + if (!globalPreferences) return projectPreferences; + if (!projectPreferences) return globalPreferences; + + return { + path: projectPreferences.path, + scope: "project", + preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences), + }; +} + +// ─── Skill Reference Resolution ─────────────────────────────────────────────── + +export interface SkillResolution { + /** The original reference from preferences (bare name or path). */ + original: string; + /** The resolved absolute path to the SKILL.md file, or null if unresolved. */ + resolvedPath: string | null; + /** How it was resolved. */ + method: "absolute-path" | "absolute-dir" | "user-skill" | "project-skill" | "unresolved"; +} + +export interface SkillResolutionReport { + /** All resolution results, keyed by original reference. */ + resolutions: Map; + /** References that could not be resolved. */ + warnings: string[]; +} + +/** + * Known skill directories, in priority order. + * User skills (~/.pi/agent/skills/) take precedence over project skills. + */ +function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> { + return [ + { dir: join(getAgentDir(), "skills"), method: "user-skill" }, + { dir: join(cwd, ".pi", "agent", "skills"), method: "project-skill" }, + ]; +} + +/** + * Resolve a single skill reference to an absolute path. + * + * Resolution order: + * 1. Absolute path to a file → check existsSync + * 2. Absolute path to a directory → check for SKILL.md inside + * 3. Bare name → scan known skill directories for /SKILL.md + */ +function resolveSkillReference(ref: string, cwd: string): SkillResolution { + const trimmed = ref.trim(); + + // Expand tilde + const expanded = trimmed.startsWith("~/") + ? join(homedir(), trimmed.slice(2)) + : trimmed; + + // Absolute path + if (isAbsolute(expanded)) { + // Direct file reference + if (existsSync(expanded)) { + // Check if it's a directory — look for SKILL.md inside + try { + const stat = statSync(expanded); + if (stat.isDirectory()) { + const skillFile = join(expanded, "SKILL.md"); + if (existsSync(skillFile)) { + return { original: ref, resolvedPath: skillFile, method: "absolute-dir" }; + } + return { original: ref, resolvedPath: null, method: "unresolved" }; + } + } catch { /* fall through */ } + return { original: ref, resolvedPath: expanded, method: "absolute-path" }; + } + // Maybe it's a directory path without SKILL.md suffix + const withSkillMd = join(expanded, "SKILL.md"); + if (existsSync(withSkillMd)) { + return { original: ref, resolvedPath: withSkillMd, method: "absolute-dir" }; + } + return { original: ref, resolvedPath: null, method: "unresolved" }; + } + + // Bare name — scan known skill directories + for (const { dir, method } of getSkillSearchDirs(cwd)) { + if (!existsSync(dir)) continue; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === expanded) { + const skillFile = join(dir, entry.name, "SKILL.md"); + if (existsSync(skillFile)) { + return { original: ref, resolvedPath: skillFile, method }; + } + } + } + } catch { /* directory not readable — skip */ } + } + + return { original: ref, resolvedPath: null, method: "unresolved" }; +} + +/** + * Resolve all skill references in a preferences object. + * Caches resolution per reference string to avoid redundant filesystem scans. + */ +export function resolveAllSkillReferences(preferences: GSDPreferences, cwd: string): SkillResolutionReport { + const validated = validatePreferences(preferences).preferences; + preferences = validated; + + const resolutions = new Map(); + const warnings: string[] = []; + + function resolve(ref: string): SkillResolution { + const existing = resolutions.get(ref); + if (existing) return existing; + const result = resolveSkillReference(ref, cwd); + resolutions.set(ref, result); + if (result.method === "unresolved") { + warnings.push(ref); + } + return result; + } + + // Resolve all skill lists + for (const skill of preferences.always_use_skills ?? []) resolve(skill); + for (const skill of preferences.prefer_skills ?? []) resolve(skill); + for (const skill of preferences.avoid_skills ?? []) resolve(skill); + + // Resolve skill rules + for (const rule of preferences.skill_rules ?? []) { + for (const skill of rule.use ?? []) resolve(skill); + for (const skill of rule.prefer ?? []) resolve(skill); + for (const skill of rule.avoid ?? []) resolve(skill); + } + + return { resolutions, warnings }; +} + +/** + * Format a skill reference for the system prompt. + * If resolved, shows the path so the agent knows exactly where to read. + * If unresolved, marks it clearly. + */ +function formatSkillRef(ref: string, resolutions: Map): string { + const resolution = resolutions.get(ref); + if (!resolution || resolution.method === "unresolved") { + return `${ref} (⚠ not found — check skill name or path)`; + } + // For absolute paths where SKILL.md is just appended, don't clutter the output + if (resolution.method === "absolute-path" || resolution.method === "absolute-dir") { + return ref; + } + // For bare names resolved from skill directories, show the resolved path + return `${ref} → \`${resolution.resolvedPath}\``; +} + +// ─── System Prompt Rendering ────────────────────────────────────────────────── + +export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, resolutions?: Map): string { + const validated = validatePreferences(preferences); + const lines: string[] = ["## GSD Skill Preferences"]; + + if (validated.errors.length > 0) { + lines.push("- Validation: some preference values were ignored because they were invalid."); + } + + preferences = validated.preferences; + + lines.push( + "- Treat these as explicit skill-selection policy for GSD work.", + "- If a listed skill exists and is relevant, load and follow it instead of treating it as a vague suggestion.", + "- Current user instructions still override these defaults.", + ); + + const fmt = (ref: string) => resolutions ? formatSkillRef(ref, resolutions) : ref; + + if (preferences.always_use_skills && preferences.always_use_skills.length > 0) { + lines.push("- Always use these skills when relevant:"); + for (const skill of preferences.always_use_skills) { + lines.push(` - ${fmt(skill)}`); + } + } + + if (preferences.prefer_skills && preferences.prefer_skills.length > 0) { + lines.push("- Prefer these skills when relevant:"); + for (const skill of preferences.prefer_skills) { + lines.push(` - ${fmt(skill)}`); + } + } + + if (preferences.avoid_skills && preferences.avoid_skills.length > 0) { + lines.push("- Avoid these skills unless clearly needed:"); + for (const skill of preferences.avoid_skills) { + lines.push(` - ${fmt(skill)}`); + } + } + + if (preferences.skill_rules && preferences.skill_rules.length > 0) { + lines.push("- Situational rules:"); + for (const rule of preferences.skill_rules) { + lines.push(` - When ${rule.when}:`); + if (rule.use && rule.use.length > 0) { + lines.push(` - use: ${rule.use.map(fmt).join(", ")}`); + } + if (rule.prefer && rule.prefer.length > 0) { + lines.push(` - prefer: ${rule.prefer.map(fmt).join(", ")}`); + } + if (rule.avoid && rule.avoid.length > 0) { + lines.push(` - avoid: ${rule.avoid.map(fmt).join(", ")}`); + } + } + } + + if (preferences.custom_instructions && preferences.custom_instructions.length > 0) { + lines.push("- Additional instructions:"); + for (const instruction of preferences.custom_instructions) { + lines.push(` - ${instruction}`); + } + } + + return lines.join("\n"); +} + +function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedGSDPreferences | null { + if (!existsSync(path)) return null; + + const raw = readFileSync(path, "utf-8"); + const preferences = parsePreferencesMarkdown(raw); + if (!preferences) return null; + + return { + path, + scope, + preferences, + }; +} + +function parsePreferencesMarkdown(content: string): GSDPreferences | null { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + return parseFrontmatterBlock(match[1]); +} + +function parseFrontmatterBlock(frontmatter: string): GSDPreferences { + const root: Record = {}; + const stack: Array<{ indent: number; value: Record }> = [{ indent: -1, value: root }]; + + const lines = frontmatter.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const trimmed = line.trim(); + + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { + stack.pop(); + } + + const current = stack[stack.length - 1].value; + const keyMatch = trimmed.match(/^([A-Za-z0-9_]+):(.*)$/); + if (!keyMatch) continue; + + const [, key, remainder] = keyMatch; + const valuePart = remainder.trim(); + + if (valuePart === "") { + const nextLine = lines[i + 1] ?? ""; + const nextTrimmed = nextLine.trim(); + if (nextTrimmed.startsWith("- ")) { + const items: unknown[] = []; + let j = i + 1; + while (j < lines.length) { + const candidate = lines[j]; + const candidateIndent = candidate.match(/^\s*/)?.[0].length ?? 0; + const candidateTrimmed = candidate.trim(); + if (!candidateTrimmed) { + j++; + continue; + } + if (candidateIndent <= indent || !candidateTrimmed.startsWith("- ")) break; + + const itemText = candidateTrimmed.slice(2).trim(); + const nextCandidate = lines[j + 1] ?? ""; + const nextCandidateIndent = nextCandidate.match(/^\s*/)?.[0].length ?? 0; + const nextCandidateTrimmed = nextCandidate.trim(); + + if (itemText.includes(":") || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) { + const obj: Record = {}; + const firstMatch = itemText.match(/^([A-Za-z0-9_]+):(.*)$/); + if (firstMatch) { + obj[firstMatch[1]] = parseScalar(firstMatch[2].trim()); + } + j++; + while (j < lines.length) { + const nested = lines[j]; + const nestedIndent = nested.match(/^\s*/)?.[0].length ?? 0; + const nestedTrimmed = nested.trim(); + if (!nestedTrimmed) { + j++; + continue; + } + if (nestedIndent <= candidateIndent) break; + const nestedMatch = nestedTrimmed.match(/^([A-Za-z0-9_]+):(.*)$/); + if (nestedMatch) { + const nestedValue = nestedMatch[2].trim(); + if (nestedValue === "") { + const nestedItems: string[] = []; + j++; + while (j < lines.length) { + const nestedArrayLine = lines[j]; + const nestedArrayIndent = nestedArrayLine.match(/^\s*/)?.[0].length ?? 0; + const nestedArrayTrimmed = nestedArrayLine.trim(); + if (!nestedArrayTrimmed) { + j++; + continue; + } + if (nestedArrayIndent <= nestedIndent || !nestedArrayTrimmed.startsWith("- ")) break; + nestedItems.push(String(parseScalar(nestedArrayTrimmed.slice(2).trim()))); + j++; + } + obj[nestedMatch[1]] = nestedItems; + continue; + } + obj[nestedMatch[1]] = parseScalar(nestedValue); + } + j++; + } + items.push(obj); + continue; + } + + items.push(parseScalar(itemText)); + j++; + } + current[key] = items; + i = j - 1; + } else { + const obj: Record = {}; + current[key] = obj; + stack.push({ indent, value: obj }); + } + continue; + } + + current[key] = parseScalar(valuePart); + } + + return root as GSDPreferences; +} + +function parseScalar(value: string): string | number | boolean { + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+$/.test(value)) return Number(value); + return value.replace(/^['\"]|['\"]$/g, ""); +} + +/** + * Resolve the skill discovery mode from effective preferences. + * Defaults to "suggest" — skills are identified during research but not installed automatically. + */ +export function resolveSkillDiscoveryMode(): SkillDiscoveryMode { + const prefs = loadEffectiveGSDPreferences(); + return prefs?.preferences.skill_discovery ?? "suggest"; +} + +/** + * Resolve which model ID to use for a given auto-mode unit type. + * Returns undefined if no model preference is set for this unit type. + */ +export function resolveModelForUnit(unitType: string): string | undefined { + const prefs = loadEffectiveGSDPreferences(); + if (!prefs?.preferences.models) return undefined; + const m = prefs.preferences.models; + + switch (unitType) { + case "research-milestone": + case "research-slice": + return m.research; + case "plan-milestone": + case "plan-slice": + case "replan-slice": + return m.planning; + case "execute-task": + return m.execution; + case "complete-slice": + case "run-uat": + return m.completion; + default: + return undefined; + } +} + +export function resolveAutoSupervisorConfig(): AutoSupervisorConfig { + const prefs = loadEffectiveGSDPreferences(); + const configured = prefs?.preferences.auto_supervisor ?? {}; + + return { + soft_timeout_minutes: configured.soft_timeout_minutes ?? 20, + idle_timeout_minutes: configured.idle_timeout_minutes ?? 10, + hard_timeout_minutes: configured.hard_timeout_minutes ?? 30, + ...(configured.model ? { model: configured.model } : {}), + }; +} + +function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences { + return { + version: override.version ?? base.version, + always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills), + prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills), + avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills), + skill_rules: [...(base.skill_rules ?? []), ...(override.skill_rules ?? [])], + custom_instructions: mergeStringLists(base.custom_instructions, override.custom_instructions), + models: { ...(base.models ?? {}), ...(override.models ?? {}) }, + skill_discovery: override.skill_discovery ?? base.skill_discovery, + auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) }, + uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, + budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, + }; +} + +function validatePreferences(preferences: GSDPreferences): { + preferences: GSDPreferences; + errors: string[]; +} { + const errors: string[] = []; + const validated: GSDPreferences = {}; + + if (preferences.version !== undefined) { + if (preferences.version === 1) { + validated.version = 1; + } else { + errors.push(`unsupported version ${preferences.version}`); + } + } + + const validDiscoveryModes = new Set(["auto", "suggest", "off"]); + if (preferences.skill_discovery) { + if (validDiscoveryModes.has(preferences.skill_discovery)) { + validated.skill_discovery = preferences.skill_discovery; + } else { + errors.push(`invalid skill_discovery value: ${preferences.skill_discovery}`); + } + } + + validated.always_use_skills = normalizeStringList(preferences.always_use_skills); + validated.prefer_skills = normalizeStringList(preferences.prefer_skills); + validated.avoid_skills = normalizeStringList(preferences.avoid_skills); + validated.custom_instructions = normalizeStringList(preferences.custom_instructions); + + if (preferences.skill_rules) { + const validRules: GSDSkillRule[] = []; + for (const rule of preferences.skill_rules) { + if (!rule || typeof rule !== "object") { + errors.push("invalid skill_rules entry"); + continue; + } + const when = typeof rule.when === "string" ? rule.when.trim() : ""; + if (!when) { + errors.push("skill_rules entry missing when"); + continue; + } + const validatedRule: GSDSkillRule = { when }; + for (const action of SKILL_ACTIONS) { + const values = normalizeStringList((rule as Record)[action]); + if (values.length > 0) { + validatedRule[action as keyof GSDSkillRule] = values as never; + } + } + if (!validatedRule.use && !validatedRule.prefer && !validatedRule.avoid) { + errors.push(`skill rule has no actions: ${when}`); + continue; + } + validRules.push(validatedRule); + } + if (validRules.length > 0) { + validated.skill_rules = validRules; + } + } + + for (const key of ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const) { + if (validated[key] && validated[key]!.length === 0) { + delete validated[key]; + } + } + + if (preferences.uat_dispatch !== undefined) { + validated.uat_dispatch = !!preferences.uat_dispatch; + } + + if (preferences.budget_ceiling !== undefined) { + const raw = preferences.budget_ceiling; + if (typeof raw === "number" && Number.isFinite(raw)) { + validated.budget_ceiling = raw; + } else if (typeof raw === "string" && Number.isFinite(Number(raw))) { + validated.budget_ceiling = Number(raw); + } else { + errors.push("budget_ceiling must be a finite number"); + } + } + + return { preferences: validated, errors }; +} + +function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined { + const merged = [ + ...normalizeStringList(base), + ...normalizeStringList(override), + ] + .map((item) => item.trim()) + .filter(Boolean); + return merged.length > 0 ? Array.from(new Set(merged)) : undefined; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); +} diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts new file mode 100644 index 000000000..64e86ca08 --- /dev/null +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -0,0 +1,50 @@ +/** + * GSD Prompt Loader + * + * Reads .md prompt templates from the prompts/ directory and substitutes + * {{variable}} placeholders with provided values. + * + * Templates live at prompts/ relative to this module's directory. + * They use {{variableName}} syntax for substitution. + */ + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const promptsDir = join(dirname(fileURLToPath(import.meta.url)), "prompts"); + +/** + * Load a prompt template and substitute variables. + * + * @param name - Template filename without .md extension (e.g. "execute-task") + * @param vars - Key-value pairs to substitute for {{key}} placeholders + */ +export function loadPrompt(name: string, vars: Record = {}): string { + const path = join(promptsDir, `${name}.md`); + let content = readFileSync(path, "utf-8"); + + // Check BEFORE substitution: find all {{varName}} placeholders the template + // declares and verify every one has a value in vars. Checking after substitution + // would also flag {{...}} patterns injected by inlined content (e.g. template + // files embedded in {{inlinedContext}}), producing false positives. + const declared = content.match(/\{\{[a-zA-Z][a-zA-Z0-9_]*\}\}/g); + if (declared) { + const missing = [...new Set(declared)] + .map(m => m.slice(2, -2)) + .filter(key => !(key in vars)); + if (missing.length > 0) { + throw new Error( + `loadPrompt("${name}"): template declares {{${missing.join("}}, {{")}}}} but no value was provided. ` + + `This usually means the extension code in memory is older than the template on disk. ` + + `Restart pi to reload the extension.` + ); + } + } + + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + + return content.trim(); +} diff --git a/src/resources/extensions/gsd/prompts/complete-milestone.md b/src/resources/extensions/gsd/prompts/complete-milestone.md new file mode 100644 index 000000000..5e44806bc --- /dev/null +++ b/src/resources/extensions/gsd/prompts/complete-milestone.md @@ -0,0 +1,25 @@ +You are executing GSD auto-mode. + +## UNIT: Complete Milestone {{milestoneId}} ("{{milestoneTitle}}") + +All relevant context has been preloaded below — the roadmap, all slice summaries, requirements, decisions, and project context are inlined. Start working immediately without re-reading these files. + +{{inlinedContext}} + +Then: +1. Read the milestone-summary template at `~/.pi/agent/extensions/gsd/templates/milestone-summary.md` +2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules +3. Verify each **success criterion** from the milestone definition in `{{roadmapPath}}`. For each criterion, confirm it was met with specific evidence from slice summaries, test results, or observable behavior. List any criterion that was NOT met. +4. Verify the milestone's **definition of done** — all slices are `[x]`, all slice summaries exist, and any cross-slice integration points work correctly. +5. Validate **requirement status transitions**. For each requirement that changed status during this milestone, confirm the transition is supported by evidence. Requirements can move between Active, Validated, Deferred, Blocked, or Out of Scope — but only with proof. +6. Write `{{milestoneSummaryAbsPath}}` using the milestone-summary template. Fill all frontmatter fields and narrative sections. The `requirement_outcomes` field must list every requirement that changed status with `from_status`, `to_status`, and `proof`. +7. Update `.gsd/REQUIREMENTS.md` if any requirement status transitions were validated in step 5. +8. Update `.gsd/PROJECT.md` to reflect milestone completion and current project state. +9. Commit all changes: `git add -A && git commit -m 'feat(gsd): complete {{milestoneId}}'` +10. Update `.gsd/STATE.md` + +**Important:** Do NOT skip the success criteria and definition of done verification (steps 3-4). The milestone summary must reflect actual verified outcomes, not assumed success. If any criterion was not met, document it clearly in the summary and do not mark the milestone as passing verification. + +**You MUST write `{{milestoneSummaryAbsPath}}` AND update PROJECT.md before finishing.** + +When done, say: "Milestone {{milestoneId}} complete." diff --git a/src/resources/extensions/gsd/prompts/complete-slice.md b/src/resources/extensions/gsd/prompts/complete-slice.md new file mode 100644 index 000000000..d6a65b3ac --- /dev/null +++ b/src/resources/extensions/gsd/prompts/complete-slice.md @@ -0,0 +1,27 @@ +You are executing GSD auto-mode. + +## UNIT: Complete Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} + +All relevant context has been preloaded below — the slice plan, all task summaries, and the milestone roadmap are inlined. Start working immediately without re-reading these files. + +{{inlinedContext}} + +Then: +1. Read the templates: + - `~/.pi/agent/extensions/gsd/templates/slice-summary.md` + - `~/.pi/agent/extensions/gsd/templates/uat.md` +2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules +3. Run all slice-level verification checks defined in the slice plan. All must pass before marking the slice done. If any fail, fix them first. +4. Confirm the slice's observability/diagnostic surfaces are real and useful where relevant: status inspection works, failure state is externally visible, structured errors/logs are actionable, and hidden failures are not being mistaken for success. +5. If `.gsd/REQUIREMENTS.md` exists, update it based on what this slice actually proved. Move requirements between Active, Validated, Deferred, Blocked, or Out of Scope only when the evidence from execution supports that change. Surface any new candidate requirements discovered during execution instead of silently dropping them. +6. Write `{{sliceSummaryAbsPath}}` (compress all task summaries). Fill the requirement-related sections explicitly. +7. Write `{{sliceUatAbsPath}}`. Fill the new `UAT Type`, `Requirements Proved By This UAT`, and `Not Proven By This UAT` sections explicitly. +8. Review task summaries for `key_decisions`. Ensure any significant architectural, pattern, or observability decisions are in `.gsd/DECISIONS.md`. If any are missing, append them now. +9. Mark {{sliceId}} done in `{{roadmapPath}}` (change `[ ]` to `[x]`) +10. Commit all remaining slice changes: `git add -A && git commit -m 'feat(gsd): complete {{sliceId}}'`. Do not squash-merge manually; the extension will merge the slice branch back to main after this unit succeeds. +11. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed. +12. Update `.gsd/STATE.md` + +**You MUST mark {{sliceId}} as `[x]` in `{{roadmapPath}}` AND write `{{sliceSummaryAbsPath}}` before finishing.** + +When done, say: "Slice {{sliceId}} complete." diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md new file mode 100644 index 000000000..6f86bb018 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -0,0 +1,151 @@ +{{preamble}} + +Say exactly: "What's the vision?" — nothing else. Wait for the user's answer. + +## Discussion Phase + +After they describe it, your job is to understand the project deeply enough to define the project's capability contract before planning slices. + +## Vision Mapping + +Before diving into detailed Q&A, read the user's description and classify its scale: + +- **Task** — a focused piece of work (single milestone, few slices) +- **Project** — a coherent product with multiple major capabilities (multi-milestone likely) +- **Product/Platform** — a large vision with distinct phases, audiences, or systems (definitely multi-milestone) + +**For Project or Product/Platform scale:** Before drilling into details, map the full landscape: +1. Propose a milestone sequence — names, one-line intents, rough dependencies +2. Present this to the user for confirmation or adjustment +3. Only then begin the deep Q&A — and scope the Q&A to the full vision, not just M001 + +**For Task scale:** Proceed directly to the discussion flow below (single milestone). + +**Anti-reduction rule:** If the user describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or try to reduce scope unless the user explicitly asks for an MVP or minimal version. When something is complex or risky, phase it into a later milestone — do not cut it. The user's ambition is the target, and your job is to sequence it intelligently, not shrink it. + +--- + +**If the user provides a file path or pastes a large document** (spec, design doc, product plan, chat export), read it fully before asking questions. Use it as the starting point — don't ask them to re-explain what's already in the document. Your questions should fill gaps and resolve ambiguities the document doesn't cover. + +**Investigate between question rounds to make your questions smarter.** Before each round of questions, do enough lightweight research that your questions are grounded in reality — not guesses about what exists or what's possible. + +- Check library docs (`resolve_library` / `get_library_docs`) when the user mentions tech you need current facts about — capabilities, constraints, API shapes, version-specific behavior +- Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. +- Scout the codebase (`ls`, `find`, `rg`, or `scout` for broad unfamiliar areas) to understand what already exists, what patterns are established, what constraints current code imposes + +Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. + +**Use this to actively surface:** +- The biggest technical unknowns — what could fail, what hasn't been proven, what might invalidate the plan +- Integration surfaces — external systems, APIs, libraries, or internal modules this work touches +- What needs to be proven before committing — the things that, if they don't work, mean the plan is wrong +- Product reality requirements: primary user loop, launchability expectations, continuity expectations, and failure visibility expectations +- Items that are complex, risky, or lower priority — phase these into later milestones rather than deferring or cutting them. Only truly unwanted capabilities become anti-features. + +**Then use ask_user_questions** to dig into gray areas — architecture choices, scope boundaries, tech preferences, what's in vs out. 1-3 questions per round. + +If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during discuss/planning work, but do not let it override the required discuss flow or artifact requirements. + +**Self-regulate depth by scale:** +- **Task scale:** After about 5-10 questions total (2-3 rounds), or when you feel you have a solid understanding, offer to proceed. +- **Project/Product scale:** After about 15-25 questions total (5-8 rounds), or when you feel you have a solid understanding, offer to proceed. + +Include a question like: +"I think I have a good picture. Ready to confirm requirements and milestone plan, or are there more things to discuss?" +with options: "Ready to confirm requirements and milestone plan (Recommended)", "I have more to discuss" + +If the user wants to keep going, keep asking. If they're ready, proceed. + +## Focused Research + +For a new project or any project that does not yet have `.gsd/REQUIREMENTS.md`, do a focused research pass before roadmap creation. + +Research is advisory, not auto-binding. Use the discussion output to identify: +- table stakes the product space usually expects +- domain-standard behaviors the user may or may not want +- likely omissions that would make the product feel incomplete +- plausible anti-features or scope traps +- differentiators worth preserving + +If the research suggests requirements the user did not explicitly ask for, present them as candidate requirements to confirm, defer, or reject. Do not silently turn research into scope. + +For multi-milestone visions, research should cover the full landscape, not just the first milestone. Research findings may affect milestone sequencing, not just slice ordering within M001. + +## Capability Contract + +Before writing a roadmap, produce or update `.gsd/REQUIREMENTS.md`. + +Use it as the project's explicit capability contract. + +Requirements must be organized into: +- Active +- Validated +- Deferred +- Out of Scope +- Traceability + +Each requirement should include: +- stable ID (`R###`) +- title +- class +- status +- description +- why it matters +- source (`user`, `inferred`, `research`, or `execution`) +- primary owning slice +- supporting slices +- validation status +- notes + +Rules: +- Keep requirements capability-oriented, not a giant feature inventory +- Every Active requirement must either be mapped to a roadmap owner, explicitly deferred, blocked with reason, or moved out of scope +- Product-facing work should capture launchability, primary user loop, continuity, and failure visibility when relevant +- Later milestones may have provisional ownership, but the first planned milestone should map requirements to concrete slices wherever possible + +For multi-milestone projects, requirements should span the full vision. Requirements owned by later milestones get provisional ownership. The full requirement set captures the user's complete vision — milestones are the sequencing strategy, not the scope boundary. + +If the project is new or has no `REQUIREMENTS.md`, confirm candidate requirements with the user before writing the roadmap. Keep the confirmation lightweight: confirm, defer, reject, or add. + +## Scope Assessment + +Confirm the scale assessment from Vision Mapping still holds after discussion. If the scope grew or shrank significantly during Q&A, adjust the milestone count accordingly. + +If Vision Mapping classified the work as Task but discussion revealed Project-scale complexity, upgrade to multi-milestone and propose the split. If Vision Mapping classified it as Project but the scope narrowed to a single coherent body of work (roughly 2-12 slices), downgrade to single-milestone. + +## Output Phase + +### Naming Convention + +Directories use bare IDs. Files use ID-SUFFIX format. Titles live inside file content, not in names. +- Milestone dir: `.gsd/milestones/{{milestoneId}}/` +- Milestone files: `{{milestoneId}}-CONTEXT.md`, `{{milestoneId}}-ROADMAP.md` +- Slice dirs: `S01/`, `S02/`, etc. + +### Single Milestone + +Once the user is satisfied, in a single pass: +1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` +2. Write or update `.gsd/PROJECT.md` — read the template at `~/.pi/agent/extensions/gsd/templates/project.md` first. Describe what the project is, its current state, and list the milestone sequence. +3. Write or update `.gsd/REQUIREMENTS.md` — read the template at `~/.pi/agent/extensions/gsd/templates/requirements.md` first. Confirm requirement states, ownership, and traceability before roadmap creation. +4. Write `{{contextAbsPath}}` — read the template at `~/.pi/agent/extensions/gsd/templates/context.md` first. Preserve key risks, unknowns, existing codebase constraints, integration points, and relevant requirements surfaced during discussion. +5. Write `{{roadmapAbsPath}}` — read the template at `~/.pi/agent/extensions/gsd/templates/roadmap.md` first. Decompose into demoable vertical slices with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. +6. Seed `.gsd/DECISIONS.md` — read the template at `~/.pi/agent/extensions/gsd/templates/decisions.md` first. Append rows for any architectural or pattern decisions made during discussion. +7. Update `.gsd/STATE.md` +8. Commit: `docs({{milestoneId}}): context, requirements, and roadmap` + +After writing the files and committing, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Auto-mode will start automatically. + +### Multi-Milestone + +Once the user confirms the milestone split, in a single pass: +1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone +2. Write `.gsd/PROJECT.md` — read the template at `~/.pi/agent/extensions/gsd/templates/project.md` first. +3. Write `.gsd/REQUIREMENTS.md` — read the template at `~/.pi/agent/extensions/gsd/templates/requirements.md` first. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet. +4. Write a `CONTEXT.md` for **every** milestone — capture the intent, scope, risks, constraints, user-visible outcome, completion class, final integrated acceptance, and relevant requirements for each. Each future milestone's CONTEXT.md should be rich enough that a planning agent encountering it fresh — with no memory of this conversation — can understand the intent, constraints, dependencies, what this milestone unlocks, and what "done" looks like. +5. Write a `ROADMAP.md` for **only the first milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. +6. Seed `.gsd/DECISIONS.md`. +7. Update `.gsd/STATE.md` +8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count) + +After writing the files and committing, say exactly: "Milestone M001 ready." — nothing else. Auto-mode will start automatically. diff --git a/src/resources/extensions/gsd/prompts/doctor-heal.md b/src/resources/extensions/gsd/prompts/doctor-heal.md new file mode 100644 index 000000000..3270ae070 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/doctor-heal.md @@ -0,0 +1,29 @@ +You are executing GSD doctor heal mode. + +The doctor has already scanned the repo and optionally applied deterministic fixes. You are now responsible for resolving the remaining issues using the smallest safe set of changes. + +Rules: +1. Prioritize the active milestone or the explicitly requested scope. Do not fan out across unrelated historical milestones unless the report explicitly scopes you there. +2. Read before edit. +3. Prefer fixing authoritative artifacts over masking warnings. +4. For missing summaries or UAT files, generate the real artifact from existing slice/task context when possible — do not leave placeholders if you can reconstruct the real content. +5. After each repair cluster, verify the relevant invariant directly from disk. +6. When done, rerun `/gsd doctor {{doctorCommandSuffix}}` mentally by ensuring the remaining issue set for this scope is reduced or cleared. + +## Doctor Summary + +{{doctorSummary}} + +## Structured Issues + +{{structuredIssues}} + +## Requested Scope + +{{scopeLabel}} + +Then: +- Repair the unresolved issues in scope +- Keep changes minimal and targeted +- If unresolved issues remain outside scope, leave them untouched and mention them briefly +- End with: "GSD doctor heal complete." \ No newline at end of file diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md new file mode 100644 index 000000000..0adfa9e3a --- /dev/null +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -0,0 +1,64 @@ +You are executing GSD auto-mode. + +## UNIT: Execute Task {{taskId}} ("{{taskTitle}}") — Slice {{sliceId}} ("{{sliceTitle}}"), Milestone {{milestoneId}} + +Start with the inlined context below. Treat the inlined task plan as the authoritative local execution contract for this unit. Use the referenced source artifacts to verify details, resolve ambiguity, and run the required checks — do not waste time reconstructing context that is already provided here. + +{{resumeSection}} + +{{carryForwardSection}} + +{{taskPlanInline}} + +{{slicePlanExcerpt}} + +## Backing Source Artifacts +- Slice plan: `{{planPath}}` +- Task plan source: `{{taskPlanPath}}` +- Prior task summaries in this slice: +{{priorTaskLines}} + +Then: +1. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during execution, without relaxing required verification or artifact rules +2. Execute the steps in the inlined task plan +3. Build the real thing. If the task plan says "create login endpoint", build an endpoint that actually authenticates against a real store, not one that returns a hardcoded success response. If the task plan says "create dashboard page", build a page that renders real data from the API, not a component with hardcoded props. Stubs and mocks are for tests, not for the shipped feature. +4. Write or update tests as part of execution — tests are verification, not an afterthought. If the slice plan defines test files in its Verification section and this is the first task, create them (they should initially fail). +5. When implementing non-trivial runtime behavior, add or preserve agent-usable observability: + - Prefer structured logs/events, stable error codes/types, and explicit status surfaces over ad hoc console text + - Ensure failures are externally inspectable rather than swallowed or hidden + - Persist high-value failure state when it materially improves retries, recovery, or later debugging + - Never log secrets, tokens, or sensitive raw payloads unnecessarily +6. Verify must-haves are met by running concrete checks (tests, commands, observable behaviors) +7. Run the slice-level verification checks defined in the slice plan's Verification section. Track which pass. On the final task of the slice, all must pass before marking done. On intermediate tasks, partial passes are expected — note which ones pass in the summary. +8. If the task touches UI, browser flows, DOM behavior, or user-visible web state: + - exercise the real flow in the browser + - prefer `browser_batch` when the next few actions are obvious and sequential + - prefer `browser_assert` for explicit pass/fail verification of the intended outcome + - use `browser_diff` when an action's effect is ambiguous + - use console/network/dialog diagnostics when validating async, stateful, or failure-prone UI + - record verification in terms of explicit checks passed/failed, not only prose interpretation +9. If observability or diagnostics were part of this task's scope, verify them directly — e.g. structured errors, status inspection, health endpoints, persisted failure state, browser/network diagnostics, or equivalent. +10. **If execution is running long or verification fails:** + + **Context budget:** If you've used most of your context and haven't finished all steps, stop implementing and prioritize writing the task summary with clear notes on what's done and what remains. A partial summary that enables clean resumption is more valuable than one more half-finished step with no documentation. Never sacrifice summary quality for one more implementation step. + + **Debugging discipline:** If a verification check fails or implementation hits unexpected behavior: + - Form a hypothesis first. State what you think is wrong and why, then test that specific theory. Don't shotgun-fix. + - Change one variable at a time. Make one change, test, observe. Multiple simultaneous changes mean you can't attribute what worked. + - Read completely. When investigating, read entire functions and their imports, not just the line that looks relevant. + - Distinguish "I know" from "I assume." Observable facts (the error says X) are strong evidence. Assumptions (this library should work this way) need verification. + - Know when to stop. If you've tried 3+ fixes without progress, your mental model is probably wrong. Stop. List what you know for certain. List what you've ruled out. Form fresh hypotheses from there. + - Don't fix symptoms. Understand *why* something fails before changing code. A test that passes after a change you don't understand is luck, not a fix. +11. **Blocker discovery:** If execution reveals that the remaining slice plan is fundamentally invalid — not just a bug or minor deviation, but a plan-invalidating finding like a wrong API, missing capability, or architectural mismatch — set `blocker_discovered: true` in the task summary frontmatter and describe the blocker clearly in the summary narrative. Do NOT set `blocker_discovered: true` for ordinary debugging, minor deviations, or issues that can be fixed within the current task or the remaining plan. This flag triggers an automatic replan of the slice. +12. If you made an architectural, pattern, library, or observability decision during this task that downstream work should know about, append it to `.gsd/DECISIONS.md` (read the template at `~/.pi/agent/extensions/gsd/templates/decisions.md` if the file doesn't exist yet). Not every task produces decisions — only append when a meaningful choice was made. +13. Read the template at `~/.pi/agent/extensions/gsd/templates/task-summary.md` +14. Write `{{taskSummaryAbsPath}}` +15. Mark {{taskId}} done in `{{planPath}}` (change `[ ]` to `[x]`) +16. Commit your work: `git add -A && git commit -m 'feat({{sliceId}}/{{taskId}}): '`. If `git add` silently fails to stage files (a known git worktree stat-cache bug), use this workaround per file: `git update-index --cacheinfo 100644,$(git hash-object -w ),` then commit. If that also fails, move on — the system will auto-commit remaining changes after your session ends. +17. Update `.gsd/STATE.md` + +You are on the slice branch. All work stays here. + +**You MUST mark {{taskId}} as `[x]` in `{{planPath}}` AND write `{{taskSummaryAbsPath}}` before finishing.** + +When done, say: "Task {{taskId}} complete." diff --git a/src/resources/extensions/gsd/prompts/guided-complete-slice.md b/src/resources/extensions/gsd/prompts/guided-complete-slice.md new file mode 100644 index 000000000..8c57978ad --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-complete-slice.md @@ -0,0 +1 @@ +Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. All tasks are done. Read the templates at `~/.pi/agent/extensions/gsd/templates/slice-summary.md` and `uat.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules. Write `{{sliceId}}-SUMMARY.md` (compress task summaries), write `{{sliceId}}-UAT.md`, and fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Mark the slice checkbox done in the roadmap, update STATE.md, update milestone summary, and leave the slice branch clean for the extension to squash-merge back to main automatically. diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md new file mode 100644 index 000000000..703889b30 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md @@ -0,0 +1,3 @@ +Discuss milestone {{milestoneId}} ("{{milestoneTitle}}"). Identify gray areas, ask the user about them, and write `{{milestoneId}}-CONTEXT.md` in the milestone directory with the decisions. Read the template at `~/.pi/agent/extensions/gsd/templates/context.md` first. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow; do not override required artifact rules. + +**Investigate between question rounds to make your questions smarter.** Before each round of questions, do enough lightweight research that your questions are grounded in reality — not guesses about what exists or what's possible. Check library docs (`resolve_library`/`get_library_docs`) when tech choices are relevant, search the web (`search-the-web` with `freshness`/`domain` filters, then `fetch_page` for full content) to verify the landscape, scout the codebase (`rg`, `find`, `scout`) to understand what already exists. Don't go deep — just enough that your next question reflects what's actually true. The goal is to ask questions the user can't answer by saying "did you check the docs?" or "look at the code." diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-slice.md b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md new file mode 100644 index 000000000..101f58c98 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md @@ -0,0 +1,59 @@ +You are interviewing the user to surface behavioural, UX, and usage grey areas for slice **{{sliceId}}: {{sliceTitle}}** of milestone **{{milestoneId}}**. + +Your goal is **not** to settle tech stack, naming conventions, or architecture — that happens during research and planning. Your goal is to produce a context file that captures the human decisions: what this slice should feel like, how it should behave, what edge cases matter, where scope begins and ends, and what the user cares about that won't be obvious from the roadmap entry alone. + +{{inlinedContext}} + +--- + +## Interview Protocol + +### Before your first question round + +Do a lightweight targeted investigation so your questions are grounded in reality: +- Scout the codebase (`rg`, `find`, or `scout` for broad unfamiliar areas) to understand what already exists that this slice touches or builds on +- Check the roadmap context above to understand what surrounds this slice — what comes before, what depends on it +- Identify the 3–5 biggest behavioural unknowns: things where the user's answer will materially change what gets built + +Do **not** go deep — just enough that your questions reflect what's actually true rather than what you assume. + +### Question rounds + +Ask **1–3 questions per round** using `ask_user_questions`. Keep each question focused on one of: +- **UX and user-facing behaviour** — what does the user see, click, trigger, or experience? +- **Edge cases and failure states** — what happens when things go wrong or are in unusual states? +- **Scope boundaries** — what is explicitly in vs out for this slice? What deferred to later? +- **Feel and experience** — tone, responsiveness, feedback, transitions, what "done" feels like to the user + +After the user answers, investigate further if any answer opens a new unknown, then ask the next round. + +### Check-in after each round + +After each round of answers, use `ask_user_questions` to ask: + +> "I think I have a solid picture of this slice. Ready to wrap up and write the context file, or is there more to cover?" + +Options: +- "Wrap up — write the context file" *(recommended after ~2–3 rounds)* +- "Keep going — more to discuss" + +If the user wants to keep going, keep asking. Stop when they say wrap up. + +--- + +## Output + +Once the user is ready to wrap up: + +1. Read the slice context template at `~/.pi/agent/extensions/gsd/templates/slice-context.md` +2. `mkdir -p {{sliceDirAbsPath}}` +3. Write `{{contextAbsPath}}` — use the template structure, filling in: + - **Goal** — one sentence: what this slice delivers + - **Why this Slice** — why now, what it unblocks + - **Scope / In Scope** — what was confirmed in scope during the interview + - **Scope / Out of Scope** — what was explicitly deferred or excluded + - **Constraints** — anything the user flagged as a hard constraint + - **Integration Points** — what this slice consumes and produces + - **Open Questions** — anything still unresolved, with current thinking +4. Commit: `git -C {{projectRoot}} add {{contextAbsPath}} && git -C {{projectRoot}} commit -m "docs({{milestoneId}}/{{sliceId}}): slice context from discuss"` +5. Say exactly: `"{{sliceId}} context written."` — nothing else. diff --git a/src/resources/extensions/gsd/prompts/guided-execute-task.md b/src/resources/extensions/gsd/prompts/guided-execute-task.md new file mode 100644 index 000000000..8f576eddb --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-execute-task.md @@ -0,0 +1 @@ +Execute the next task: {{taskId}} ("{{taskTitle}}") in slice {{sliceId}} of milestone {{milestoneId}}. Read the task plan (`{{taskId}}-PLAN.md`), load relevant summaries from prior tasks, and execute each step. Verify must-haves when done. If the task touches UI, browser flows, DOM behavior, or user-visible web state, exercise the real flow in the browser, prefer `browser_batch` for obvious sequences, prefer `browser_assert` for explicit pass/fail verification, use `browser_diff` when an action's effect is ambiguous, and use browser diagnostics when validating async or failure-prone UI. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. Read the template at `~/.pi/agent/extensions/gsd/templates/task-summary.md`. Write `{{taskId}}-SUMMARY.md`, mark it done, commit, and advance. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during execution, without relaxing required verification or artifact rules. If running long and not all steps are finished, stop implementing and prioritize writing a clean partial summary over attempting one more step — a recoverable handoff is more valuable than a half-finished step with no documentation. If verification fails, debug methodically: form a hypothesis and test that specific theory before changing anything, change one variable at a time, read entire functions not just the suspect line, distinguish observable facts from assumptions, and if 3+ fixes fail without progress stop and reassess your mental model — list what you know for certain, what you've ruled out, and form fresh hypotheses. Don't fix symptoms — understand why something fails before changing code. diff --git a/src/resources/extensions/gsd/prompts/guided-plan-milestone.md b/src/resources/extensions/gsd/prompts/guided-plan-milestone.md new file mode 100644 index 000000000..519e03dc6 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-plan-milestone.md @@ -0,0 +1,23 @@ +Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, continue in legacy compatibility mode but explicitly note missing requirement coverage. Read the template at `~/.pi/agent/extensions/gsd/templates/roadmap.md`. Create `{{milestoneId}}-ROADMAP.md` in the milestone directory with slices, risk levels, dependencies, demo sentences, verification classes, milestone definition of done, requirement coverage, and a boundary map. Write success criteria as observable truths, not implementation tasks. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required roadmap formatting. + +## Requirement Rules + +- Every relevant Active requirement must be mapped to a slice, deferred, blocked with reason, or moved out of scope. +- Each requirement gets one primary owner and may have supporting slices. +- Surface orphaned Active requirements instead of silently ignoring them. +- Product-facing milestones should cover launchability, primary user loop, continuity, and failure visibility when relevant. + +## Planning Doctrine + +- **Risk-first means proof-first.** The earliest slices should prove the hardest thing works by shipping the real feature through the uncertain path. If auth is the risk, the first slice ships a real login page with real session handling that a user can actually use — not a CLI command that returns "authenticated: true". Proof is the shipped feature working. There is no separate "proof" artifact. Do not plan spikes, proof-of-concept slices, or validation-only slices — the proof is the real feature, built through the risky path. +- **Every slice is vertical, demoable, and shippable.** Every slice ships real, user-facing functionality. "Demoable" means you could show a stakeholder and they'd see real product progress — not a developer showing a terminal command. If the only way to demonstrate the slice is through a test runner or a curl command, the slice is missing its UI/UX surface. Add it. A slice that only proves something but doesn't ship real working code is not a slice — restructure it. +- **Brownfield bias.** When planning against an existing codebase, ground slices in existing modules, conventions, and seams. Prefer extending real patterns over inventing new ones. +- **Each slice should establish something downstream slices can depend on.** Think about what stable surface this slice creates for later work — an API, a data shape, a proven integration path. +- **Avoid foundation-only slices.** If a slice doesn't produce something demoable end-to-end, it's probably a layer, not a vertical slice. Restructure it. +- **Verification-first.** When planning slices, know what "done" looks like before detailing implementation. Each slice's demo line should describe concrete, verifiable evidence — not vague "it works" claims. +- **Plan for integrated reality, not just local proof.** Distinguish contract proof from live integration proof. If the milestone involves multiple runtime boundaries, one slice must explicitly prove the assembled system through the real entrypoint or runtime path. +- **Truthful demo lines only.** If a slice is proven by fixtures or tests only, say so. Do not phrase harness-level proof as if the user can already perform the live end-to-end behavior unless that has actually been exercised. +- **Completion must imply capability.** If every slice in this roadmap were completed exactly as written, the milestone's promised outcome should actually work at the proof level claimed. Do not write slices that can all be checked off while the user-visible capability still does not exist. +- **Don't invent risks.** If the project is straightforward, skip the proof strategy and just ship value in smart order. Not everything has major unknowns. +- **Ship features, not proofs.** A completed slice should leave the product in a state where the new capability is actually usable through its real interface. A login flow slice ends with a working login page, not a middleware function. An API slice ends with endpoints that return real data from a real store, not hardcoded fixtures. A dashboard slice ends with a real dashboard rendering real data, not a component that renders mock props. If a slice can't ship the real thing yet because a dependency isn't built, it should ship with realistic stubs that are clearly marked for replacement — but the user-facing surface must be real. +- **Ambition matches the milestone.** The number and depth of slices should match the milestone's ambition. A milestone promising "core platform with auth, data model, and primary user loop" should have enough slices to actually deliver all three as working features — not two proof-of-concept slices and a note that "the rest will come in the next milestone." If the milestone's context promises an outcome, the roadmap must deliver it. diff --git a/src/resources/extensions/gsd/prompts/guided-plan-slice.md b/src/resources/extensions/gsd/prompts/guided-plan-slice.md new file mode 100644 index 000000000..aca537232 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-plan-slice.md @@ -0,0 +1 @@ +Plan slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements the roadmap says this slice owns or supports, and ensure the plan delivers them. Read the roadmap boundary map, any existing context/research files, and dependency summaries. Read the templates at `~/.pi/agent/extensions/gsd/templates/plan.md` and `task-plan.md`. Decompose into tasks with must-haves. Fill the `Proof Level` and `Integration Closure` sections truthfully so the plan says what class of proof this slice really delivers and what end-to-end wiring still remains. Write `{{sliceId}}-PLAN.md` and individual `T##-PLAN.md` files in the `tasks/` subdirectory. If planning produces structural decisions, append them to `.gsd/decisions.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required plan formatting. Before committing, self-audit the plan: every must-have maps to at least one task, every task has complete sections (steps, must-haves, verification, observability impact, inputs, and expected output), task ordering is consistent with no circular references, every pair of artifacts that must connect has an explicit wiring step, task scope targets 2–5 steps and 3–8 files (6–8 steps or 8–10 files — consider splitting; 10+ steps or 12+ files — must split), the plan honors locked decisions from context/research/decisions artifacts, the proof-level wording does not overclaim live integration if only fixture/contract proof is planned, every Active requirement this slice owns has at least one task with verification that proves it is met, and every task produces real user-facing progress — if the slice has a UI surface at least one task builds the real UI, if it has an API at least one task connects it to a real data source, and showing the completed result to a non-technical stakeholder would demonstrate real product progress rather than developer artifacts. diff --git a/src/resources/extensions/gsd/prompts/guided-research-slice.md b/src/resources/extensions/gsd/prompts/guided-research-slice.md new file mode 100644 index 000000000..162189770 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-research-slice.md @@ -0,0 +1,11 @@ +Research slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions, don't contradict them. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements this slice owns or supports and target research toward risks, unknowns, and constraints that could affect delivery of those requirements. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during research, without relaxing required verification or artifact rules. Explore the relevant code — use `rg`/`find` for targeted reads, or `scout` if the area is broad or unfamiliar. Check libraries with `resolve_library`/`get_library_docs`. Read the template at `~/.pi/agent/extensions/gsd/templates/research.md`. Write `{{sliceId}}-RESEARCH.md` in the slice directory with summary, don't-hand-roll, common pitfalls, and relevant code sections. + +## Strategic Questions to Answer + +Research should drive planning decisions, not just collect facts. Explicitly address: + +- **What should be proven first?** What's the riskiest assumption — the thing that, if wrong, invalidates downstream work? +- **What existing patterns should be reused?** What modules, conventions, or infrastructure already exist that the plan should build on rather than reinvent? +- **What boundary contracts matter?** What interfaces, data shapes, event formats, or invariants will slices need to agree on? +- **What constraints does the existing codebase impose?** What can't be changed, what's expensive to change, what patterns must be respected? +- **Are there known failure modes that should shape slice ordering?** Pitfalls that mean certain work should come before or after other work? diff --git a/src/resources/extensions/gsd/prompts/guided-resume-task.md b/src/resources/extensions/gsd/prompts/guided-resume-task.md new file mode 100644 index 000000000..65133deb0 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/guided-resume-task.md @@ -0,0 +1 @@ +Resume interrupted work. Find the continue file (`{{sliceId}}-CONTINUE.md` or `continue.md`) in slice {{sliceId}} of milestone {{milestoneId}}, then pick up from where you left off. Delete the continue file after reading it. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during execution, without relaxing required verification or artifact rules. diff --git a/src/resources/extensions/gsd/prompts/plan-milestone.md b/src/resources/extensions/gsd/prompts/plan-milestone.md new file mode 100644 index 000000000..d5d8ae5a8 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/plan-milestone.md @@ -0,0 +1,47 @@ +You are executing GSD auto-mode. + +## UNIT: Plan Milestone {{milestoneId}} ("{{milestoneTitle}}") + +All relevant context has been preloaded below — start working immediately without re-reading these files. + +{{inlinedContext}} + +Then: +1. Read the template at `~/.pi/agent/extensions/gsd/templates/roadmap.md` +2. Read `.gsd/REQUIREMENTS.md` if it exists. Treat **Active** requirements as the capability contract for planning. If it does not exist, continue in legacy compatibility mode but explicitly note that requirement coverage is operating without a contract. +3. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required roadmap formatting +4. Create the roadmap: decompose into demoable vertical slices — as many as the work needs, no more +5. Order by risk (high-risk first) +6. Write `{{outputPath}}` with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, **requirement coverage**, and a boundary map. Write success criteria as observable truths, not implementation tasks. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment +7. If planning produced structural decisions (e.g. slice ordering rationale, technology choices, scope exclusions), append them to `.gsd/DECISIONS.md` (read the template at `~/.pi/agent/extensions/gsd/templates/decisions.md` if the file doesn't exist yet) +8. Update `.gsd/STATE.md` + +## Requirement Mapping Rules + +- Every Active requirement relevant to this milestone must be in one of these states by the end of planning: mapped to a slice, explicitly deferred, blocked with reason, or moved out of scope. +- Each requirement should have one accountable primary owner and may have supporting slices. +- Product-facing milestones should cover launchability, primary user loop, continuity, and failure visibility when relevant. +- A slice may support multiple requirements, but should not exist with no requirement justification unless it is clearly enabling work for a mapped requirement. +- Include a compact coverage summary in the roadmap so omissions are mechanically visible. +- If `.gsd/REQUIREMENTS.md` exists and an Active requirement has no credible path, surface that clearly. Do not silently ignore orphaned Active requirements. + +## Planning Doctrine + +Apply these when decomposing and ordering slices: + +- **Risk-first means proof-first.** The earliest slices should prove the hardest thing works by shipping the real feature through the uncertain path. If auth is the risk, the first slice ships a real login page with real session handling that a user can actually use — not a CLI command that returns "authenticated: true". Proof is the shipped feature working. There is no separate "proof" artifact. Do not plan spikes, proof-of-concept slices, or validation-only slices — the proof is the real feature, built through the risky path. +- **Every slice is vertical, demoable, and shippable.** Every slice ships real, user-facing functionality. "Demoable" means you could show a stakeholder and they'd see real product progress — not a developer showing a terminal command. If the only way to demonstrate the slice is through a test runner or a curl command, the slice is missing its UI/UX surface. Add it. A slice that only proves something but doesn't ship real working code is not a slice — restructure it. +- **Brownfield bias.** When planning against an existing codebase, ground slices in existing modules, conventions, and seams. Prefer extending real patterns over inventing new ones. +- **Each slice should establish something downstream slices can depend on.** Think about what stable surface this slice creates for later work — an API, a data shape, a proven integration path. +- **Avoid foundation-only slices.** If a slice doesn't produce something demoable end-to-end, it's probably a layer, not a vertical slice. Restructure it. +- **Verification-first.** When planning slices, know what "done" looks like before detailing implementation. Each slice's demo line should describe concrete, verifiable evidence — not vague "it works" claims. +- **Plan for integrated reality, not just local proof.** Distinguish contract proof from live integration proof. If the milestone involves multiple runtime boundaries, one slice must explicitly prove the assembled system through the real entrypoint or runtime path. +- **Truthful demo lines only.** If a slice is proven by fixtures or tests only, say so. Do not phrase harness-level proof as if the user can already perform the live end-to-end behavior unless that has actually been exercised. +- **Completion must imply capability.** If every slice in this roadmap were completed exactly as written, the milestone's promised outcome should actually work at the proof level claimed. Do not write slices that can all be checked off while the user-visible capability still does not exist. +- **Don't invent risks.** If the project is straightforward, skip the proof strategy and just ship value in smart order. Not everything has major unknowns. +- **Ship features, not proofs.** A completed slice should leave the product in a state where the new capability is actually usable through its real interface. A login flow slice ends with a working login page, not a middleware function. An API slice ends with endpoints that return real data from a real store, not hardcoded fixtures. A dashboard slice ends with a real dashboard rendering real data, not a component that renders mock props. If a slice can't ship the real thing yet because a dependency isn't built, it should ship with realistic stubs that are clearly marked for replacement — but the user-facing surface must be real. +- **Ambition matches the milestone.** The number and depth of slices should match the milestone's ambition. A milestone promising "core platform with auth, data model, and primary user loop" should have enough slices to actually deliver all three as working features — not two proof-of-concept slices and a note that "the rest will come in the next milestone." If the milestone's context promises an outcome, the roadmap must deliver it. + +**You MUST write the file `{{outputAbsPath}}` before finishing.** + +When done, say: "Milestone {{milestoneId}} planned." diff --git a/src/resources/extensions/gsd/prompts/plan-slice.md b/src/resources/extensions/gsd/prompts/plan-slice.md new file mode 100644 index 000000000..032558233 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/plan-slice.md @@ -0,0 +1,63 @@ +You are executing GSD auto-mode. + +## UNIT: Plan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} + +All relevant context has been preloaded below — start working immediately without re-reading these files. + +{{inlinedContext}} + +### Dependency Slice Summaries + +Pay particular attention to **Forward Intelligence** sections — they contain hard-won knowledge about what's fragile, what assumptions changed, and what this slice should watch out for. + +{{dependencySummaries}} + +Then: +0. If `REQUIREMENTS.md` was preloaded above, identify which Active requirements the roadmap says this slice owns or supports. These are the requirements this plan must deliver — every owned requirement needs at least one task that directly advances it, and verification must prove the requirement is met. +1. Read the templates: + - `~/.pi/agent/extensions/gsd/templates/plan.md` + - `~/.pi/agent/extensions/gsd/templates/task-plan.md` +2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required plan formatting +3. Define slice-level verification first — the objective stopping condition for this slice: + - For non-trivial slices: plan actual test files with real assertions. Name the files. The first task creates them (initially failing). Remaining tasks make them pass. + - For simple slices: executable commands or script assertions are fine. + - If the project is non-trivial and has no test framework, the first task should set one up. + - If this slice establishes a boundary contract, verification must exercise that contract. +4. Plan observability and diagnostics explicitly: + - For non-trivial backend, integration, async, stateful, or UI slices, include an `Observability / Diagnostics` section in the slice plan. + - Define how a future agent will inspect state, detect failure, and localize the problem. + - Prefer structured logs/events, stable error codes/types, status surfaces, and persisted failure state over ad hoc debug text. + - Include at least one verification check for a diagnostic or failure-path signal when relevant. +5. Fill the `Proof Level` and `Integration Closure` sections truthfully: + - State whether the slice proves contract, integration, operational, or final-assembly behavior. + - Say whether real runtime or human/UAT is required. + - Name the wiring introduced in this slice and what still remains before the milestone is truly usable end-to-end. +6. Decompose the slice into tasks, each fitting one context window +7. Every task in the slice plan should be written as an executable increment with: + - a concrete, action-oriented title + - the inline task entry fields defined in the plan.md template (Why / Files / Do / Verify / Done when) + - a matching task plan containing description, steps, must-haves, verification, observability impact, inputs, and expected output +8. Each task needs: title, description, steps, must-haves, verification, observability impact, inputs, and expected output +9. If verification includes test files, ensure the first task includes creating them with expected assertions (they should fail initially — that's correct) +10. Write `{{outputPath}}` +11. Write individual task plans in `{{sliceAbsPath}}/tasks/`: `T01-PLAN.md`, `T02-PLAN.md`, etc. +12. **Self-audit the plan before continuing.** Walk through each check — if any fail, fix the plan files before moving on: + - **Completion semantics:** If every task were completed exactly as written, the slice goal/demo should actually be true at the claimed proof level. Do not allow a task plan that only scaffolds toward a future working state. + - **Requirement coverage:** Every must-have in the slice maps to at least one task. No must-have is orphaned. + - **Task completeness:** Every task has steps, must-haves, verification, observability impact, inputs, and expected output — none are blank or vague. + - **Dependency correctness:** Task ordering is consistent. No task references work from a later task. + - **Key links planned:** For every pair of artifacts that must connect (component → API, API → database, form → handler), there is an explicit step that wires them — not just "create X" and "create Y" in separate tasks with no connection step. + - **Scope sanity:** Target 2–5 steps and 3–8 files per task. 6–8 steps or 8–10 files is a warning — consider splitting. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window. + - **Context compliance:** If context/research artifacts or `.gsd/DECISIONS.md` exist, the plan honors locked decisions and doesn't include deferred or out-of-scope items. + - **Requirement coverage:** If `REQUIREMENTS.md` exists, every Active requirement this slice owns (per the roadmap) maps to at least one task with verification that proves the requirement is met. No owned requirement is left without a task. No task claims to satisfy a requirement that is Deferred or Out of Scope. + - **Proof honesty:** The `Proof Level` and `Integration Closure` sections match what this slice will actually prove, and they do not imply live end-to-end completion if only fixture or contract proof is planned. + - **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding. If the slice has a UI surface, at least one task builds the real UI (not a placeholder). If the slice has an API, at least one task connects it to a real data source (not hardcoded returns). If every task were completed and you showed the result to a non-technical stakeholder, they should see real product progress, not developer artifacts. +13. If planning produced structural decisions (e.g. verification strategy, observability strategy, technology choices, patterns to follow), append them to `.gsd/DECISIONS.md` +14. Commit: `docs({{sliceId}}): add slice plan` +15. Update `.gsd/STATE.md` + +The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. You are on the slice branch; all work stays here. + +**You MUST write the file `{{outputAbsPath}}` before finishing.** + +When done, say: "Slice {{sliceId}} planned." diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md new file mode 100644 index 000000000..5e085ffb0 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -0,0 +1,85 @@ +{{preamble}} + +Say exactly: "What do you want to add?" — nothing else. Wait for the user's answer. + +## Discussion Phase + +After they describe it, your job is to understand the new work deeply enough to create context files that a future planning session can use. + +**If the user provides a file path or pastes a large document** (spec, design doc, product plan, chat export), read it fully before asking questions. Use it as the starting point — don't ask them to re-explain what's already in the document. Your questions should fill gaps and resolve ambiguities the document doesn't cover. + +**Investigate between question rounds to make your questions smarter.** Before each round of questions, do enough lightweight research that your questions are grounded in reality — not guesses about what exists or what's possible. + +- Check library docs (`resolve_library` / `get_library_docs`) when the user mentions tech you need current facts about — capabilities, constraints, API shapes, version-specific behavior +- Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. +- Scout the codebase (`ls`, `find`, `rg`, or `scout` for broad unfamiliar areas) to understand what already exists, what patterns are established, what constraints current code imposes + +Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. + +**Use this to actively surface:** +- The biggest technical unknowns — what could fail, what hasn't been proven, what might invalidate the plan +- Integration surfaces — external systems, APIs, libraries, or internal modules this work touches +- What needs to be proven before committing — the things that, if they don't work, mean the plan is wrong +- How the new work relates to existing milestones — overlap, dependencies, prerequisites +- If `.gsd/REQUIREMENTS.md` exists: which unmet Active or Deferred requirements this queued work advances + +**Then use ask_user_questions** to dig into gray areas — architecture choices, scope boundaries, tech preferences, what's in vs out. 1-3 questions per round. + +If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during discuss/planning work, but do not let it override the required discuss flow or artifact requirements. + +**Self-regulate:** After about 10-15 questions total (3-5 rounds), or when you feel you have a solid understanding, include a question like: +"I think I have a good picture. Ready to queue this, or are there more things to discuss?" +with options: "Ready to queue (Recommended)", "I have more to discuss" + +If the user wants to keep going, keep asking. If they're ready, proceed. + +## Existing Milestone Awareness + +{{existingMilestonesContext}} + +Before writing anything, assess the new work against what already exists: + +1. **Dedup check** — Is this already covered (fully or partially) by an existing milestone? If so, tell the user and explain what's already planned. Don't create duplicate milestones. +2. **Extension check** — Should this be added to an existing *pending* (not yet started) milestone rather than creating a new one? If the scope naturally belongs with existing pending work, propose extending that milestone's context instead. +3. **Dependency check** — Does the new work depend on something that's currently in progress or planned? Note the dependency so context files capture it. +4. **Requirement check** — If `.gsd/REQUIREMENTS.md` exists, identify whether this queued work advances unmet Active requirements, promotes Deferred work, or introduces entirely new scope that should also update the requirement contract. + +If the new work is already fully covered, say so and stop — don't create anything. + +## Scope Assessment + +Before writing artifacts, assess whether this is **single-milestone** or **multi-milestone** scope. + +**Single milestone** if the work is one coherent body of deliverables that fits in roughly 2-12 slices. + +**Multi-milestone** if: +- The work has natural phase boundaries +- Different parts could ship independently on different timelines +- The full scope is too large for one milestone to stay focused +- The document/spec describes what is clearly multiple major efforts + +If multi-milestone: propose the split to the user before writing artifacts. + +## Sequencing + +Determine where the new milestones should go in the overall sequence. Consider dependencies, prerequisites, and independence. + +## Output Phase + +Once the user is satisfied, in a single pass for **each** new milestone (starting from {{nextId}}): + +1. `mkdir -p .gsd/milestones//slices` +2. Write `.gsd/milestones//-CONTEXT.md` — read the template at `~/.pi/agent/extensions/gsd/templates/context.md` first. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution." + +Then, after all milestone directories and context files are written: + +3. Update `.gsd/PROJECT.md` — add the new milestones to the Milestone Sequence. Keep existing entries exactly as they are. Only add new lines. +4. If `.gsd/REQUIREMENTS.md` exists and the queued work introduces new in-scope capabilities or promotes Deferred items, update it. +5. If discussion produced decisions relevant to existing work, append to `.gsd/DECISIONS.md`. +6. Append to `.gsd/QUEUE.md`. +7. Commit: `docs: queue ` + +**Do NOT write roadmaps for queued milestones.** +**Do NOT update `.gsd/STATE.md`.** + +After writing the files and committing, say exactly: "Queued N milestone(s). Auto-mode will pick them up after current work completes." — nothing else. diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md new file mode 100644 index 000000000..e21346897 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -0,0 +1,48 @@ +You are executing GSD auto-mode. + +## UNIT: Reassess Roadmap — Milestone {{milestoneId}} after {{completedSliceId}} + +All relevant context has been preloaded below — the current roadmap, completed slice summary, project state, and decisions are inlined. Start working immediately without re-reading these files. + +{{inlinedContext}} + +If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during reassessment, without relaxing required verification or artifact rules. + +Then assess whether the remaining roadmap still makes sense given what was just built. + +**Bias strongly toward "roadmap is fine."** Most of the time, the plan is still good. Only rewrite if you have concrete evidence that remaining slices need to change. Don't rewrite for cosmetic reasons, minor optimization, or theoretical improvements. + +Ask yourself: +- Did this slice retire the risk it was supposed to? If not, does a remaining slice need to address it? +- Did new risks or unknowns emerge that should change slice ordering? +- Are the boundary contracts in the boundary map still accurate given what was actually built? +- Should any remaining slices be reordered, merged, split, or adjusted based on concrete evidence? +- Did assumptions in remaining slice descriptions turn out wrong? +- If `.gsd/REQUIREMENTS.md` exists: did this slice validate, invalidate, defer, block, or newly surface requirements? +- If `.gsd/REQUIREMENTS.md` exists: does the remaining roadmap still provide credible coverage for Active requirements, including launchability, primary user loop, continuity, and failure visibility where relevant? + +### Success-Criterion Coverage Check + +Before deciding whether changes are needed, enumerate each success criterion from the roadmap's `## Success Criteria` section and map it to the remaining (unchecked) slice(s) that prove it. Each criterion must have at least one remaining owning slice. If any criterion has no remaining owner after the proposed changes, flag it as a **blocking issue** — do not accept changes that leave a criterion unproved. + +Format each criterion as a single line: + +- `Criterion text → S02, S03` (covered by at least one remaining slice) +- `Criterion text → ⚠ no remaining owner — BLOCKING` (no slice proves this criterion) + +If all criteria have at least one remaining owning slice, the coverage check passes. If any criterion has no remaining owner, resolve it before finalizing the assessment — either by keeping a slice that was going to be removed, adding coverage to another slice, or explaining why the criterion is no longer relevant. + +**If the roadmap is still good:** + +Write `{{assessmentAbsPath}}` with a brief confirmation that roadmap coverage still holds after {{completedSliceId}}. If requirements exist, explicitly note whether requirement coverage remains sound. + +**If changes are needed:** + +1. Rewrite the remaining (unchecked) slices in `{{roadmapPath}}`. Keep completed slices exactly as they are (`[x]`). Update the boundary map for changed slices. Update the proof strategy if risks changed. Update requirement coverage if ownership or scope changed. +2. Write `{{assessmentAbsPath}}` explaining what changed and why — keep it brief and concrete. +3. If `.gsd/REQUIREMENTS.md` exists and requirement ownership or status changed, update it. +4. Commit: `docs({{milestoneId}}): reassess roadmap after {{completedSliceId}}` + +**You MUST write the file `{{assessmentAbsPath}}` before finishing.** + +When done, say: "Roadmap reassessed." diff --git a/src/resources/extensions/gsd/prompts/replan-slice.md b/src/resources/extensions/gsd/prompts/replan-slice.md new file mode 100644 index 000000000..0473c0818 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/replan-slice.md @@ -0,0 +1,39 @@ +You are executing GSD auto-mode. + +## UNIT: Replan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} + +A completed task reported `blocker_discovered: true`, meaning the current slice plan cannot be executed as-is. Your job is to rewrite the remaining tasks in the slice plan to address the blocker while preserving all completed work. + +All relevant context has been preloaded below — the roadmap, current slice plan, the blocker task summary, and decisions are inlined. Start working immediately without re-reading these files. + +{{inlinedContext}} + +## Hard Constraints + +- **Do NOT renumber or remove completed tasks.** All `[x]` tasks and their IDs must remain exactly as they are in the plan. +- **Do NOT change completed task descriptions, estimates, or metadata.** They are historical records. +- **Preserve completed task summaries.** Do not modify any `T0x-SUMMARY.md` files for completed tasks. +- Only modify `[ ]` (incomplete) tasks. You may rewrite, reorder, add, or remove incomplete tasks as needed to address the blocker. +- New tasks must follow the existing ID numbering sequence (e.g., if T01–T03 exist, new tasks start at T04 or continue from the highest existing ID). + +## Instructions + +1. Read the blocker task summary carefully. Understand exactly what was discovered and why it blocks the current plan. +2. Analyze the remaining `[ ]` tasks in the slice plan. Determine which are still valid, which need modification, and which should be replaced. +3. Write `{{replanAbsPath}}` documenting: + - What blocker was discovered and in which task + - What changed in the plan and why + - Which incomplete tasks were modified, added, or removed + - Any new risks or considerations introduced by the replan +4. Rewrite `{{planPath}}` with the updated slice plan: + - Keep all `[x]` tasks exactly as they were (same IDs, same descriptions, same checkmarks) + - Update the `[ ]` tasks to address the blocker + - Ensure the slice Goal and Demo sections are still achievable with the new tasks, or update them if the blocker fundamentally changes what the slice can deliver + - Update the Files Likely Touched section if the replan changes which files are affected +5. If any incomplete task had a `T0x-PLAN.md`, remove or rewrite it to match the new task description. +6. Commit all changes: `git add -A && git commit -m 'refactor({{sliceId}}): replan after blocker in {{blockerTaskId}}'` +7. Update `.gsd/STATE.md` + +**You MUST write `{{replanAbsPath}}` and the updated slice plan before finishing.** + +When done, say: "Slice {{sliceId}} replanned." diff --git a/src/resources/extensions/gsd/prompts/research-milestone.md b/src/resources/extensions/gsd/prompts/research-milestone.md new file mode 100644 index 000000000..03e6a5801 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/research-milestone.md @@ -0,0 +1,37 @@ +You are executing GSD auto-mode. + +## UNIT: Research Milestone {{milestoneId}} ("{{milestoneTitle}}") + +All relevant context has been preloaded below — start working immediately without re-reading these files. + +{{inlinedContext}} + +Then research the codebase and relevant technologies: +1. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during research, without relaxing required verification or artifact rules +2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}} +3. Explore relevant code. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in. +4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries +5. Read the template at `~/.pi/agent/extensions/gsd/templates/research.md` +6. If `.gsd/REQUIREMENTS.md` exists, research against it. Identify which Active requirements are table stakes, likely omissions, overbuilt risks, or domain-standard behaviors the user may or may not want. +7. Write `{{outputPath}}` with: + - Summary (2-3 paragraphs, primary recommendation) + - Don't Hand-Roll table (problems with existing solutions) + - Common Pitfalls (what goes wrong, how to avoid) + - Relevant Code (existing files, patterns, integration points) + - Sources + +## Strategic Questions to Answer + +- What should be proven first? +- What existing patterns should be reused? +- What boundary contracts matter? +- What constraints does the existing codebase impose? +- Are there known failure modes that should shape slice ordering? +- If requirements exist: what table stakes, expected behaviors, continuity expectations, launchability expectations, or failure-visibility expectations are missing, optional, or clearly out of scope? +- Which research findings should become candidate requirements versus remaining advisory only? + +**Research is advisory, not auto-binding.** Surface candidate requirements clearly instead of silently expanding scope. + +**You MUST write the file `{{outputAbsPath}}` before finishing.** + +When done, say: "Milestone {{milestoneId}} researched." diff --git a/src/resources/extensions/gsd/prompts/research-slice.md b/src/resources/extensions/gsd/prompts/research-slice.md new file mode 100644 index 000000000..afcdb04e4 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/research-slice.md @@ -0,0 +1,28 @@ +You are executing GSD auto-mode. + +## UNIT: Research Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} + +All relevant context has been preloaded below — start working immediately without re-reading these files. + +{{inlinedContext}} + +### Dependency Slice Summaries + +Pay particular attention to **Forward Intelligence** sections — they contain hard-won knowledge about what's fragile, what assumptions changed, and what to watch out for. + +{{dependencySummaries}} + +Then research what this slice needs: +0. If `REQUIREMENTS.md` was preloaded above, identify which Active requirements this slice owns or supports. Research should target these requirements — surfacing risks, unknowns, and implementation constraints that could affect whether the slice actually delivers them. +1. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during research, without relaxing required verification or artifact rules +2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}} +3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first. +4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries +5. Read the template at `~/.pi/agent/extensions/gsd/templates/research.md` +6. Write `{{outputPath}}` + +The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file. + +**You MUST write the file `{{outputAbsPath}}` before finishing.** + +When done, say: "Slice {{sliceId}} researched." diff --git a/src/resources/extensions/gsd/prompts/run-uat.md b/src/resources/extensions/gsd/prompts/run-uat.md new file mode 100644 index 000000000..b7a27fdb4 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/run-uat.md @@ -0,0 +1,109 @@ +You are executing GSD auto-mode. + +## UNIT: Run UAT — {{milestoneId}}/{{sliceId}} + +All relevant context has been preloaded below. Start working immediately without re-reading these files. + +{{inlinedContext}} + +If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during UAT execution, without relaxing required verification or artifact rules. + +--- + +## UAT Instructions + +**UAT file:** `{{uatPath}}` +**UAT type:** `{{uatType}}` +**Result file to write:** `{{uatResultAbsPath}}` (relative: `{{uatResultPath}}`) + +### If UAT type is `artifact-driven` + +You are the test runner. Execute every check defined in `{{uatPath}}` directly: + +- Run shell commands with `bash` +- Run `grep` / `rg` checks against files +- Run `node` / script invocations +- Read files and verify their contents +- Check that expected artifacts exist and have correct structure + +For each check, record: +- The check description (from the UAT file) +- The command or action taken +- The actual result observed +- PASS or FAIL verdict + +After running all checks, compute the **overall verdict**: +- `PASS` — all checks passed +- `FAIL` — one or more checks failed +- `PARTIAL` — some checks passed, some failed or were skipped + +Write `{{uatResultAbsPath}}` with: + +```markdown +--- +sliceId: {{sliceId}} +uatType: {{uatType}} +verdict: PASS | FAIL | PARTIAL +date: +--- + +# UAT Result — {{sliceId}} + +## Checks + +| Check | Result | Notes | +|-------|--------|-------| +| | PASS / FAIL | | + +## Overall Verdict + + + +## Notes + + +``` + +### If UAT type is NOT `artifact-driven` (type is `{{uatType}}`) + +This UAT type requires human execution or live-runtime observation that you cannot perform mechanically. Your role is to surface it clearly for review. + +Write `{{uatResultAbsPath}}` with: + +```markdown +--- +sliceId: {{sliceId}} +uatType: {{uatType}} +verdict: surfaced-for-human-review +date: +--- + +# UAT Result — {{sliceId}} + +## UAT Type + +`{{uatType}}` — requires human execution or live-runtime verification. + +## Status + +Surfaced for human review. Auto-mode will pause after this unit so the UAT can be performed manually. + +## UAT File + +See `{{uatPath}}` for the full UAT specification and acceptance criteria. + +## Instructions for Human Reviewer + +Review `{{uatPath}}`, perform the described UAT steps, then update this file with: +- The actual verdict (PASS / FAIL / PARTIAL) +- Results for each check +- Date completed + +Once updated, run `/gsd auto` to resume auto-mode. +``` + +--- + +**You MUST write `{{uatResultAbsPath}}` before finishing.** + +When done, say: "UAT {{sliceId}} complete." diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md new file mode 100644 index 000000000..6b7d6ae60 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/system.md @@ -0,0 +1,220 @@ +## GSD — Get Stuff Done + +You are **GSD** — a coding agent that gets shit done. + +Be direct. Execute the work. Verify results. Fix root causes. Keep momentum. Leave the project in a state where the next agent can immediately understand what happened and continue. + +This project uses GSD for structured planning and execution. Artifacts live in `.gsd/`. + +If a `GSD Skill Preferences` block is present below this contract, treat it as explicit durable guidance for which skills to use, prefer, or avoid during GSD work. Follow it where it does not conflict with required GSD artifact rules, verification requirements, or higher-priority system/developer instructions. + +### Naming Convention + +Directories use bare IDs. Files use ID-SUFFIX format: + +- Milestone dirs: `M001/` +- Milestone files: `M001-CONTEXT.md`, `M001-ROADMAP.md`, `M001-RESEARCH.md` +- Slice dirs: `S01/` +- Slice files: `S01-PLAN.md`, `S01-RESEARCH.md`, `S01-SUMMARY.md`, `S01-UAT.md` +- Task files: `T01-PLAN.md`, `T01-SUMMARY.md` + +Titles live inside file content (headings, frontmatter), not in file or directory names. + +### Directory Structure + +``` +.gsd/ + PROJECT.md (living doc — what the project is right now) + DECISIONS.md (append-only register of architectural and pattern decisions) + QUEUE.md (append-only log of queued milestones via /gsd queue) + STATE.md + milestones/ + M001/ + M001-CONTEXT.md + M001-RESEARCH.md + M001-ROADMAP.md + M001-SUMMARY.md + slices/ + S01/ + S01-CONTEXT.md (optional) + S01-RESEARCH.md (optional) + S01-PLAN.md + S01-SUMMARY.md + S01-UAT.md + tasks/ + T01-PLAN.md + T01-SUMMARY.md +``` + +### Conventions + +- **PROJECT.md** is a living document describing what the project is right now — current state only, updated at slice completion when stale +- **DECISIONS.md** is an append-only register of architectural and pattern decisions — read it during planning/research, append to it during execution when a meaningful decision is made +- **Milestones** are major project phases (M001, M002, ...) +- **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins. +- **Tasks** are single-context-window units of work (T01, T02, ...) +- Checkboxes in roadmap and plan files track completion (`[ ]` → `[x]`) +- Each slice gets its own git branch: `gsd/M001/S01` +- Slices are squash-merged to main when complete +- Summaries compress prior work — read them instead of re-reading all task details +- `STATE.md` is the quick-glance status file — keep it updated after changes + +### Artifact Templates + +Templates showing the expected format for each artifact type are in: +`~/.pi/agent/extensions/gsd/templates/` + +**Always read the relevant template before writing an artifact** to match the expected structure exactly. The parsers that read these files depend on specific formatting: + +- Roadmap slices: `- [ ] **S01: Title** \`risk:level\` \`depends:[]\`` +- Plan tasks: `- [ ] **T01: Title** \`est:estimate\`` +- Summaries use YAML frontmatter + +### Activity Logs + +Auto-mode saves session logs to `.gsd/activity/` before each context wipe. +Files are sequentially numbered: `001-execute-task-M001-S01-T01.jsonl`, etc. +These are raw JSONL debug artifacts — used automatically for retry diagnostics. + +`.gsd/activity/` is automatically added to `.gitignore` during bootstrap. + +### Commands + +- `/gsd` — contextual wizard +- `/gsd auto` — auto-execute (fresh context per task) +- `/gsd stop` — stop auto-mode +- `/gsd status` — progress dashboard overlay +- `/gsd queue` — queue future milestones (safe while auto-mode is running) +- `Ctrl+Alt+G` — toggle dashboard overlay + +### Tool-routing hierarchy + +Use the lightest sufficient tool first. + +- Known file path, need contents -> `read` +- Search repo text or symbols -> `bash` with `rg` +- Search by filename or path -> `bash` with `find` or `rg --files` +- Precise existing-file change -> `read` then `edit` +- New file or full rewrite -> `write` +- Broad unfamiliar subsystem mapping -> `subagent` with `scout` +- Library, package, or framework truth -> `resolve_library` then `get_library_docs` +- Current external facts -> `search-the-web`, then `fetch_page` for full page content +- Long-running or indefinite shell commands (servers, watchers, builds) -> `bg_shell` with `start` + `wait_for_ready` +- Background process status check -> `bg_shell` with `digest` (not `output`) +- Background process debugging -> `bg_shell` with `highlights`, then `output` with `filter` +- UI behavior verification -> browser tools +- Secrets -> `secure_env_collect` + +### Web research vs browser execution + +Treat these as different jobs. + +- Use `search-the-web` + `fetch_page` for current external knowledge: release notes, product changes, pricing, news, public docs, and fast-moving ecosystem facts. +- Use browser tools for interactive execution and verification: local app flows, reproducing browser bugs, DOM behavior, navigation, auth flows, and user-visible UI outcomes. +- Do not use browser tools as a substitute for web research. +- Do not use web search as a substitute for exercising a real browser flow. + +### Verification and definition of done + +Verify according to task type. + +- Bug fix -> rerun the exact repro +- Script or CLI fix -> rerun the exact command +- UI or web fix -> verify in the browser and check console or network logs when relevant +- Env or secrets fix -> rerun the blocked workflow after applying secrets +- Refactor -> run tests or build plus a targeted smoke check +- File delete, move, or rename -> confirm filesystem state +- Docs or config change -> verify referenced paths, commands, and settings match reality + +For non-trivial backend, async, stateful, integration, or UI work, verification must cover both behavior and observability. + +- Verify the feature works +- Verify the failure path or diagnostic surface is inspectable +- Verify the chosen status/log/error surface exposes enough information for a future agent to localize problems quickly + +If a command or workflow fails, continue the loop: inspect the error, fix it, rerun it, and repeat until it passes or a real blocker requires user input. + +### Agent-First Observability + +GSD is optimized for agent autonomy. Build systems so a future agent can inspect current state, localize failures, and continue work without relying on human intuition. + +Prefer: +- Structured, machine-readable logs or events over ad hoc prose logs +- Stable error types/codes and preserved causal context over vague failures +- Explicit state transitions and status inspection surfaces over implicit behavior +- Durable diagnostics that survive the current run when they materially improve recovery +- High-signal summaries and status endpoints over log spam + +For relevant work, plan and implement: +- Health/readiness/status surfaces for services, jobs, pipelines, and long-running work +- Observable failure state: last error, phase, timestamp, identifiers, retry count, or equivalent +- Deterministic verification of both happy path and at least one diagnostic/failure-path signal +- Safe redaction boundaries: never log secrets, tokens, or sensitive raw payloads unnecessarily + +Temporary instrumentation is allowed during debugging. Remove noisy one-off instrumentation before finishing unless it provides durable diagnostic value. + +### Root-cause-first debugging + +- Fix the root cause, not just the visible symptom, unless the user explicitly wants a temporary workaround. +- Prefer changes that remove the failure mode over changes that merely mask it. +- When applying a temporary mitigation, label it clearly and preserve a path to the real fix. + +## Situational Playbooks + +### Background processes + +Use `bg_shell` instead of `bash` for any command that runs indefinitely or takes a long time. + +**Starting processes:** +- Set `type:'server'` and `ready_port:` for dev servers so readiness detection is automatic. +- Set `group:''` on related processes (e.g. frontend + backend) to manage them together. +- Use `ready_pattern:''` for processes with non-standard readiness signals. +- The tool auto-classifies commands as server/build/test/watcher/generic and applies smart defaults. + +**After starting — use `wait_for_ready` instead of polling:** +- `wait_for_ready` blocks until the process signals readiness (pattern match or port open) or times out. +- This replaces the old pattern of `start` → `sleep` → `output` → check → repeat. One tool call instead of many. + +**Checking status — use `digest` instead of `output`:** +- `digest` returns a structured ~30-token summary (status, ports, URLs, error count, change summary) instead of ~2000 tokens of raw output. Use this by default. +- `highlights` returns only significant lines (errors, URLs, results) — typically 5-15 lines instead of hundreds. +- `output` returns raw incremental lines — use only when debugging and you need full text. Add `filter:'error|warning'` to narrow results. +- Token budget hierarchy: `digest` (~30 tokens) < `highlights` (~100 tokens) < `output` (~2000 tokens). Always start with the lightest. + +**Lifecycle awareness:** +- Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll for failures. +- Use `group_status` to check health of related processes as a unit. +- Use `restart` to kill and relaunch with the same config — preserves restart count. + +**Interactive processes:** +- Use `send_and_wait` for interactive CLIs: send input and wait for an expected output pattern. Replaces manual `send` → `sleep` → `output` polling. + +**Cleanup:** +- Kill processes when done with them — do not leave orphans. +- Use `list` to see all running background processes. + +### Web behavior + +When the task involves frontend behavior, DOM interactions, navigation, or user flows, verify with browser tools against a running app before marking the work complete. + +Use browser tools with this operating order unless there is a clear reason not to: + +1. Cheap discovery first — use `browser_find` or `browser_snapshot_refs` to locate likely targets +2. Deterministic targeting — prefer refs or explicit selectors over coordinates +3. Batch obvious sequences — if the next 2-5 browser actions are clear and low-risk, use `browser_batch` +4. Assert outcomes explicitly — prefer `browser_assert` over inferring success from prose summaries +5. Diff ambiguous outcomes — use `browser_diff` when the effect of an action is unclear +6. Inspect diagnostics only when needed — use console/network/dialog logs when assertions or diffs suggest failure +7. Escalate inspection gradually — use `browser_get_accessibility_tree` only when targeted discovery is insufficient; use `browser_get_page_source` and `browser_evaluate` as escape hatches, not defaults +8. Use screenshots as supporting evidence — do not default to screenshot-first browsing when semantic tools are sufficient + +For browser or UI work, “verified” means the flow was exercised and the expected outcome was checked explicitly with `browser_assert` or an equally structured browser signal whenever possible. + +For browser failures, debug in this order: +1. inspect the failing assertion or explicit success signal +2. inspect `browser_diff` +3. inspect recent console/network/dialog diagnostics +4. inspect targeted element or accessibility state +5. only then escalate to broader page inspection + +Retry only with a new hypothesis. Do not thrash. diff --git a/src/resources/extensions/gsd/session-forensics.ts b/src/resources/extensions/gsd/session-forensics.ts new file mode 100644 index 000000000..a86062e01 --- /dev/null +++ b/src/resources/extensions/gsd/session-forensics.ts @@ -0,0 +1,487 @@ +/** + * GSD Session Forensics — Deep analysis of pi session JSONL files + * + * Pi's SessionManager persists every entry to disk via appendFileSync as it + * happens. When a crash occurs, the session JSONL on disk contains every tool + * call, every assistant response, and every error up to the moment of death. + * + * This module reads that file and reconstructs a structured execution trace + * that tells the recovering agent exactly what happened, what changed, and + * where to resume. + * + * Used by: + * - Crash recovery (reading the surviving pi session file) + * - Stuck-retry diagnostics (reading GSD activity log copies) + * + * Entry format (verified against real pi session files): + * - Tool calls: { type: "toolCall", name: "bash", id: "toolu_...", arguments: { command: "..." } } + * - Tool results: { role: "toolResult", toolCallId: "toolu_...", toolName: "bash", isError: bool, content: ... } + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { basename, join } from "node:path"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ToolCall { + name: string; + input: Record; + result?: string; + isError: boolean; +} + +export interface ExecutionTrace { + /** Ordered list of tool calls with results */ + toolCalls: ToolCall[]; + /** Files written or edited (deduplicated, ordered by first occurrence) */ + filesWritten: string[]; + /** Files read (deduplicated) */ + filesRead: string[]; + /** Shell commands executed with exit status */ + commandsRun: { command: string; failed: boolean }[]; + /** Tool errors encountered */ + errors: string[]; + /** The agent's last reasoning / text output before crash */ + lastReasoning: string; + /** Total tool calls completed (have matching results) */ + toolCallCount: number; +} + +export interface RecoveryBriefing { + /** What the agent was doing */ + unitType: string; + unitId: string; + /** Structured execution trace */ + trace: ExecutionTrace; + /** Git state: files modified/added/deleted since unit started */ + gitChanges: string | null; + /** Formatted prompt section ready for injection */ + prompt: string; +} + +// ─── JSONL Parsing ──────────────────────────────────────────────────────────── + +function parseJSONL(raw: string): unknown[] { + return raw.trim().split("\n").map(line => { + try { return JSON.parse(line); } + catch { return null; } + }).filter(Boolean) as unknown[]; +} + +/** + * Find the entries belonging to the last session in a JSONL file. + * Auto-mode creates a new session per unit, so the last session header + * marks the start of the crashed unit's entries. + */ +function extractLastSession(entries: unknown[]): unknown[] { + let lastSessionIdx = -1; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as Record; + if (entry.type === "session") { + lastSessionIdx = i; + break; + } + } + return lastSessionIdx >= 0 ? entries.slice(lastSessionIdx) : entries; +} + +// ─── Trace Extraction ───────────────────────────────────────────────────────── + +/** + * Extract a structured execution trace from raw session entries. + * Works with both pi session JSONL and GSD activity log JSONL. + */ +export function extractTrace(entries: unknown[]): ExecutionTrace { + const toolCalls: ToolCall[] = []; + const filesWritten: string[] = []; + const filesRead: string[] = []; + const commandsRun: { command: string; failed: boolean }[] = []; + const errors: string[] = []; + let lastReasoning = ""; + + // Track pending tool calls by ID for matching with results + const pendingTools = new Map }>(); + + const seenWritten = new Set(); + const seenRead = new Set(); + + for (const raw of entries) { + const entry = raw as Record; + if (entry.type !== "message" || !entry.message) continue; + const msg = entry.message as Record; + + // ── Assistant messages: tool calls + reasoning ── + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const part of msg.content as Record[]) { + // Text reasoning + if (part.type === "text" && part.text) { + lastReasoning = String(part.text); + } + + // Tool call initiation + // Pi format: { type: "toolCall", name: "bash", id: "toolu_...", arguments: { command: "..." } } + if (part.type === "toolCall") { + const name = String(part.name || "unknown").toLowerCase(); + const input = (part.arguments || part.input || {}) as Record; + const id = String(part.id || ""); + + if (id) { + pendingTools.set(id, { name, input }); + } + + // Track file operations + const path = input.path ? String(input.path) : null; + if (path) { + if (name === "write" || name === "edit") { + if (!seenWritten.has(path)) { seenWritten.add(path); filesWritten.push(path); } + } else if (name === "read") { + if (!seenRead.has(path)) { seenRead.add(path); filesRead.push(path); } + } + } + + // Track shell commands + if ((name === "bash" || name === "bg_shell") && input.command) { + commandsRun.push({ command: String(input.command), failed: false }); + } + } + } + } + + // ── Tool results: match with pending calls ── + // Pi format: { role: "toolResult", toolCallId: "toolu_...", toolName: "bash", isError: bool, content: ... } + if (msg.role === "toolResult") { + const id = String(msg.toolCallId || ""); + const isError = !!msg.isError; + const resultText = extractResultText(msg); + + const pending = pendingTools.get(id); + if (pending) { + toolCalls.push({ + name: pending.name, + input: redactInput(pending.name, pending.input), + result: resultText.slice(0, 500), + isError, + }); + pendingTools.delete(id); + + // Mark failed commands + if (isError && (pending.name === "bash" || pending.name === "bg_shell")) { + const lastCmd = findLast(commandsRun, c => c.command === String(pending.input.command)); + if (lastCmd) lastCmd.failed = true; + } + } + + if (isError && resultText) { + errors.push(resultText.slice(0, 300)); + } + } + } + + // Flush any pending tool calls that never got results (crash mid-tool) + for (const [, pending] of pendingTools) { + toolCalls.push({ + name: pending.name, + input: redactInput(pending.name, pending.input), + isError: false, + }); + } + + return { + toolCalls, + filesWritten, + filesRead, + commandsRun, + errors, + lastReasoning: lastReasoning.slice(-600).trim(), + toolCallCount: toolCalls.length, + }; +} + +// ─── Git State ──────────────────────────────────────────────────────────────── + +function getGitChanges(basePath: string): string | null { + try { + const status = execSync("git status --porcelain", { cwd: basePath, stdio: "pipe" }).toString().trim(); + if (!status) return null; + + const diffStat = execSync("git diff --stat HEAD 2>/dev/null || true", { cwd: basePath, stdio: "pipe" }).toString().trim(); + const stagedStat = execSync("git diff --stat --cached HEAD 2>/dev/null || true", { cwd: basePath, stdio: "pipe" }).toString().trim(); + + const parts: string[] = []; + if (status) parts.push(`Status:\n${status}`); + if (stagedStat) parts.push(`Staged:\n${stagedStat}`); + if (diffStat) parts.push(`Unstaged:\n${diffStat}`); + return parts.join("\n\n"); + } catch { + return null; + } +} + +// ─── Recovery Briefing ──────────────────────────────────────────────────────── + +/** + * Synthesize a full crash recovery briefing. + * + * Reads the surviving pi session file (or falls back to the last GSD activity + * log), deep-parses it into an execution trace, combines with git state, and + * formats a structured prompt section ready for injection. + */ +export function synthesizeCrashRecovery( + basePath: string, + unitType: string, + unitId: string, + sessionFile?: string, + activityDir?: string, +): RecoveryBriefing | null { + try { + let trace: ExecutionTrace | null = null; + + // Primary source: surviving pi session file + if (sessionFile && existsSync(sessionFile)) { + const raw = readFileSync(sessionFile, "utf-8"); + const allEntries = parseJSONL(raw); + const sessionEntries = extractLastSession(allEntries); + trace = extractTrace(sessionEntries); + } + + // Fallback: last GSD activity log + if (!trace || trace.toolCallCount === 0) { + const fallbackTrace = readLastActivityLog(activityDir); + if (fallbackTrace && fallbackTrace.toolCallCount > 0) { + trace = fallbackTrace; + } + } + + // If no trace from either source, still provide git state + if (!trace) { + trace = { + toolCalls: [], filesWritten: [], filesRead: [], + commandsRun: [], errors: [], lastReasoning: "", toolCallCount: 0, + }; + } + + const gitChanges = getGitChanges(basePath); + const prompt = formatRecoveryPrompt(unitType, unitId, trace, gitChanges); + + return { unitType, unitId, trace, gitChanges, prompt }; + } catch { + return null; + } +} + +/** + * Deep diagnostic from any JSONL source (activity log or session file). + * Replaces the old shallow getLastActivityDiagnostic(). + */ +export function getDeepDiagnostic(basePath: string): string | null { + const activityDir = join(basePath, ".gsd", "activity"); + const trace = readLastActivityLog(activityDir); + if (!trace || trace.toolCallCount === 0) return null; + return formatTraceSummary(trace); +} + +// ─── Formatting ─────────────────────────────────────────────────────────────── + +function formatRecoveryPrompt( + unitType: string, + unitId: string, + trace: ExecutionTrace, + gitChanges: string | null, +): string { + const sections: string[] = []; + + sections.push( + "## Crash Recovery Briefing", + "", + `You are resuming \`${unitType}\` for \`${unitId}\` after a crash.`, + `The previous session completed **${trace.toolCallCount} tool calls** before dying.`, + "Use this briefing to pick up exactly where it left off. Do NOT redo completed work.", + ); + + // Tool call trace — compact summary + if (trace.toolCalls.length > 0) { + sections.push("", "### Completed Tool Calls"); + const summary = compressToolCallTrace(trace.toolCalls); + sections.push(summary); + } + + // Files written + if (trace.filesWritten.length > 0) { + sections.push( + "", "### Files Already Written/Edited", + ...trace.filesWritten.map(f => `- \`${f}\``), + "", + "These files exist on disk from the previous run. Verify they look correct before continuing.", + ); + } + + // Commands run + const significantCommands = trace.commandsRun.filter(c => + !c.command.startsWith("git ") || c.failed, + ); + if (significantCommands.length > 0) { + sections.push("", "### Commands Already Run"); + for (const c of significantCommands.slice(-10)) { + const status = c.failed ? " ❌" : " ✓"; + sections.push(`- \`${truncate(c.command, 120)}\`${status}`); + } + } + + // Errors + if (trace.errors.length > 0) { + sections.push( + "", "### Errors Before Crash", + ...trace.errors.slice(-3).map(e => `- ${truncate(e, 200)}`), + ); + } + + // Git state + if (gitChanges) { + sections.push( + "", "### Current Git State (filesystem truth)", + "```", gitChanges, "```", + ); + } + + // Last reasoning + if (trace.lastReasoning) { + sections.push( + "", "### Last Agent Reasoning Before Crash", + `> ${trace.lastReasoning.replace(/\n/g, "\n> ")}`, + ); + } + + sections.push( + "", + "### Resume Instructions", + "1. Check the task plan for remaining work", + "2. Verify files listed above exist and look correct on disk", + "3. Continue from where the previous session left off", + "4. Do NOT re-read files or re-run commands that already succeeded above", + ); + + return sections.join("\n"); +} + +/** + * Compress a tool call trace into a readable summary. + * Groups consecutive reads, shows write/edit/bash individually. + */ +function compressToolCallTrace(calls: ToolCall[]): string { + const lines: string[] = []; + let readBatch: string[] = []; + + function flushReads() { + if (readBatch.length === 0) return; + if (readBatch.length <= 2) { + for (const path of readBatch) lines.push(` read \`${path}\``); + } else { + lines.push(` read ${readBatch.length} files: ${readBatch.map(p => `\`${basename(p)}\``).join(", ")}`); + } + readBatch = []; + } + + for (let i = 0; i < calls.length; i++) { + const call = calls[i]!; + const num = i + 1; + + if (call.name === "read" && call.input.path) { + readBatch.push(String(call.input.path)); + continue; + } + + flushReads(); + + const err = call.isError ? " ❌" : ""; + + if (call.name === "write" || call.name === "edit") { + lines.push(`${num}. ${call.name} \`${call.input.path || "?"}\`${err}`); + } else if (call.name === "bash" || call.name === "bg_shell") { + const cmd = truncate(String(call.input.command || ""), 80); + lines.push(`${num}. ${call.name}: \`${cmd}\`${err}`); + } else { + lines.push(`${num}. ${call.name}${err}`); + } + } + + flushReads(); + return lines.join("\n"); +} + +function formatTraceSummary(trace: ExecutionTrace): string { + const parts: string[] = []; + parts.push(`Tool calls completed: ${trace.toolCallCount}`); + + if (trace.filesWritten.length > 0) { + parts.push(`Files written: ${trace.filesWritten.map(f => `\`${f}\``).join(", ")}`); + } + if (trace.commandsRun.length > 0) { + const cmds = trace.commandsRun.slice(-5).map(c => `\`${truncate(c.command, 80)}\`${c.failed ? " ❌" : ""}`); + parts.push(`Commands run: ${cmds.join(", ")}`); + } + if (trace.errors.length > 0) { + parts.push(`Errors: ${trace.errors.slice(-3).join("; ")}`); + } + if (trace.lastReasoning) { + parts.push(`Last reasoning: "${trace.lastReasoning}"`); + } + return parts.join("\n"); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function readLastActivityLog(activityDir?: string): ExecutionTrace | null { + if (!activityDir) return null; + try { + if (!existsSync(activityDir)) return null; + const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort(); + if (files.length === 0) return null; + + const lastFile = files[files.length - 1]!; + const raw = readFileSync(join(activityDir, lastFile), "utf-8"); + return extractTrace(parseJSONL(raw)); + } catch { + return null; + } +} + +function extractResultText(msg: Record): string { + const content = msg.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((p: Record) => p.type === "text") + .map((p: Record) => String(p.text || "")) + .join(" "); + } + return ""; +} + +/** + * Redact sensitive fields from tool inputs. + * Keep paths and commands, drop large content bodies. + */ +function redactInput(name: string, input: Record): Record { + const safe: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (key === "content" || key === "oldText" || key === "newText") { + safe[key] = typeof value === "string" ? truncate(value, 100) : "[redacted]"; + } else { + safe[key] = value; + } + } + return safe; +} + +/** Array.findLast polyfill for older Node versions */ +function findLast(arr: T[], predicate: (item: T) => boolean): T | undefined { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i]!)) return arr[i]; + } + return undefined; +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + "…" : s; +} diff --git a/src/resources/extensions/gsd/skill-discovery.ts b/src/resources/extensions/gsd/skill-discovery.ts new file mode 100644 index 000000000..d33fc0206 --- /dev/null +++ b/src/resources/extensions/gsd/skill-discovery.ts @@ -0,0 +1,137 @@ +/** + * GSD Skill Discovery + * + * Detects skills installed during auto-mode by comparing the current + * skills directory against a snapshot taken at auto-mode start. + * + * New skills are injected into the system prompt via before_agent_start, + * making them visible to all subsequent units without requiring a reload. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getAgentDir } from "@mariozechner/pi-coding-agent"; + +const SKILLS_DIR = join(getAgentDir(), "skills"); + +export interface DiscoveredSkill { + name: string; + description: string; + location: string; +} + +/** Snapshot of skill names at auto-mode start */ +let baselineSkills: Set | null = null; + +/** + * Snapshot the current skills directory. Call at auto-mode start. + */ +export function snapshotSkills(): void { + baselineSkills = new Set(listSkillDirs()); +} + +/** + * Clear the snapshot. Call when auto-mode stops. + */ +export function clearSkillSnapshot(): void { + baselineSkills = null; +} + +/** + * Check if a snapshot is active (auto-mode is running with discovery). + */ +export function hasSkillSnapshot(): boolean { + return baselineSkills !== null; +} + +/** + * Detect skills installed since the snapshot was taken. + * Returns skill metadata for any new skills found. + */ +export function detectNewSkills(): DiscoveredSkill[] { + if (!baselineSkills) return []; + + const current = listSkillDirs(); + const newSkills: DiscoveredSkill[] = []; + + for (const dir of current) { + if (baselineSkills.has(dir)) continue; + + const skillMdPath = join(SKILLS_DIR, dir, "SKILL.md"); + if (!existsSync(skillMdPath)) continue; + + const meta = parseSkillFrontmatter(skillMdPath); + if (meta) { + newSkills.push({ + name: meta.name || dir, + description: meta.description || `Skill: ${dir}`, + location: skillMdPath, + }); + } + } + + return newSkills; +} + +/** + * Format discovered skills as an XML block matching pi's format. + * This can be appended to the system prompt so the LLM sees them naturally. + */ +export function formatSkillsXml(skills: DiscoveredSkill[]): string { + if (skills.length === 0) return ""; + + const entries = skills.map(s => ` + ${escapeXml(s.name)} + ${escapeXml(s.description)} + ${escapeXml(s.location)} + `).join("\n"); + + return `\n +The following skills were installed during this auto-mode session. +Use the read tool to load a skill's file when the task matches its description. + +${entries} +`; +} + +// ─── Internals ──────────────────────────────────────────────────────────────── + +function listSkillDirs(): string[] { + if (!existsSync(SKILLS_DIR)) return []; + try { + return readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + } catch { + return []; + } +} + +function parseSkillFrontmatter(path: string): { name?: string; description?: string } | null { + try { + const content = readFileSync(path, "utf-8"); + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + const fm = match[1]; + const result: { name?: string; description?: string } = {}; + + const nameMatch = fm.match(/^name:\s*(.+)$/m); + if (nameMatch) result.name = nameMatch[1].trim(); + + const descMatch = fm.match(/^description:\s*(.+)$/m); + if (descMatch) result.description = descMatch[1].trim(); + + return result; + } catch { + return null; + } +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts new file mode 100644 index 000000000..f14f83ad5 --- /dev/null +++ b/src/resources/extensions/gsd/state.ts @@ -0,0 +1,439 @@ +// GSD Extension — State Derivation +// Reads roadmap + plan files to determine current position. +// Pure TypeScript, zero Pi dependencies. + +import type { + GSDState, + ActiveRef, + Roadmap, + RoadmapSliceEntry, + SlicePlan, + MilestoneRegistryEntry, +} from './types.ts'; + +import { + parseRoadmap, + parsePlan, + parseSummary, + loadFile, + parseRequirementCounts, + parseContextDependsOn, +} from './files.ts'; + +import { + milestonesDir, + resolveMilestonePath, + resolveMilestoneFile, + resolveSlicePath, + resolveSliceFile, + resolveTaskFile, + resolveGsdRootFile, +} from './paths.ts'; +import { getActiveSliceBranch } from './worktree.ts'; + +import { readdirSync } from 'fs'; +import { join } from 'path'; + +// ─── Query Functions ─────────────────────────────────────────────────────── + +/** + * Check if all tasks in a slice plan are done. + */ +export function isSliceComplete(plan: SlicePlan): boolean { + return plan.tasks.length > 0 && plan.tasks.every(t => t.done); +} + +/** + * Check if all slices in a roadmap are done. + */ +export function isMilestoneComplete(roadmap: Roadmap): boolean { + return roadmap.slices.length > 0 && roadmap.slices.every(s => s.done); +} + +// ─── State Derivation ────────────────────────────────────────────────────── + +/** + * Find all milestone directory IDs by scanning .gsd/milestones/. + * Extracts the ID prefix (e.g. "M001") from directory names like "M001-PAYMENT-INTEGRATIONS". + */ +function findMilestoneIds(basePath: string): string[] { + const dir = milestonesDir(basePath); + try { + return readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => { + const match = d.name.match(/^(M\d+)/); + return match ? match[1] : d.name; + }) + .sort(); + } catch { + return []; + } +} + +/** + * Returns the ID of the first incomplete milestone, or null if all are complete. + */ +export async function getActiveMilestoneId(basePath: string): Promise { + const milestoneIds = findMilestoneIds(basePath); + for (const mid of milestoneIds) { + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const content = roadmapFile ? await loadFile(roadmapFile) : null; + if (!content) return mid; // No roadmap yet — milestone is incomplete + const roadmap = parseRoadmap(content); + if (!isMilestoneComplete(roadmap)) return mid; + } + return null; +} + +/** + * Reconstruct GSD state from files on disk. + * This is the source of truth — STATE.md is just a cache of this output. + */ +export async function deriveState(basePath: string): Promise { + const milestoneIds = findMilestoneIds(basePath); + const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); + + if (milestoneIds.length === 0) { + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: 'pre-planning', + recentDecisions: [], + blockers: [], + nextAction: 'No milestones found. Run /gsd to create one.', + registry: [], + requirements, + progress: { + milestones: { done: 0, total: 0 }, + }, + }; + } + + // Pre-compute the set of complete milestone IDs for dependency checking. + // This allows forward references (M002 depending on M003) to resolve correctly. + const completeMilestoneIds = new Set(); + for (const mid of milestoneIds) { + const rf = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const rc = rf ? await loadFile(rf) : null; + if (!rc) continue; + const rmap = parseRoadmap(rc); + if (!isMilestoneComplete(rmap)) continue; + const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (sf) completeMilestoneIds.add(mid); + } + + // Build the registry and locate the active milestone in a single pass. + const registry: MilestoneRegistryEntry[] = []; + let activeMilestone: ActiveRef | null = null; + let activeRoadmap: Roadmap | null = null; + let activeMilestoneFound = false; + + for (const mid of milestoneIds) { + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const content = roadmapFile ? await loadFile(roadmapFile) : null; + if (!content) { + // No roadmap yet — treat as incomplete/active + if (!activeMilestoneFound) { + activeMilestone = { id: mid, title: mid }; + activeMilestoneFound = true; + registry.push({ id: mid, title: mid, status: 'active' }); + } else { + registry.push({ id: mid, title: mid, status: 'pending' }); + } + continue; + } + + const roadmap = parseRoadmap(content); + const title = roadmap.title.replace(/^M\d+[^:]*:\s*/, ''); + const complete = isMilestoneComplete(roadmap); + + if (complete) { + // All slices done — check if milestone summary exists + const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (!summaryFile && !activeMilestoneFound) { + // All slices complete but no summary written yet → completing-milestone + activeMilestone = { id: mid, title }; + activeRoadmap = roadmap; + activeMilestoneFound = true; + registry.push({ id: mid, title, status: 'active' }); + } else { + registry.push({ id: mid, title, status: 'complete' }); + } + } else if (!activeMilestoneFound) { + // Check milestone-level dependencies before promoting to active + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const contextContent = contextFile ? await loadFile(contextFile) : null; + const deps = parseContextDependsOn(contextContent); + const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); + if (depsUnmet) { + registry.push({ id: mid, title, status: 'pending', dependsOn: deps }); + // Do NOT set activeMilestoneFound — let the loop continue to the next milestone + } else { + activeMilestone = { id: mid, title }; + activeRoadmap = roadmap; + activeMilestoneFound = true; + registry.push({ id: mid, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); + } + } else { + const contextFile2 = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const contextContent2 = contextFile2 ? await loadFile(contextFile2) : null; + const deps2 = parseContextDependsOn(contextContent2); + registry.push({ id: mid, title, status: 'pending', ...(deps2.length > 0 ? { dependsOn: deps2 } : {}) }); + } + } + + const milestoneProgress = { + done: registry.filter(entry => entry.status === 'complete').length, + total: registry.length, + }; + + if (!activeMilestone) { + // Check whether any milestones are pending (dep-blocked) vs all complete + const pendingEntries = registry.filter(entry => entry.status === 'pending'); + if (pendingEntries.length > 0) { + // All incomplete milestones are dep-blocked — no progress possible + const blockerDetails = pendingEntries + .filter(entry => entry.dependsOn && entry.dependsOn.length > 0) + .map(entry => `${entry.id} is waiting on unmet deps: ${entry.dependsOn!.join(', ')}`); + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: 'blocked', + recentDecisions: [], + blockers: blockerDetails.length > 0 + ? blockerDetails + : ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'], + nextAction: 'Resolve milestone dependencies before proceeding.', + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } + // All milestones complete + const lastEntry = registry[registry.length - 1]; + return { + activeMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, + activeSlice: null, + activeTask: null, + phase: 'complete', + recentDecisions: [], + blockers: [], + nextAction: 'All milestones complete.', + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } + + if (!activeRoadmap) { + // Active milestone exists but has no roadmap yet — needs planning + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: 'pre-planning', + recentDecisions: [], + blockers: [], + nextAction: `Plan milestone ${activeMilestone.id}.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } + + // Check if active milestone needs completion (all slices done, no summary) + if (isMilestoneComplete(activeRoadmap)) { + const sliceProgress = { + done: activeRoadmap.slices.length, + total: activeRoadmap.slices.length, + }; + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: 'completing-milestone', + recentDecisions: [], + blockers: [], + nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + + const sliceProgress = { + done: activeRoadmap.slices.filter(s => s.done).length, + total: activeRoadmap.slices.length, + }; + + // Find the active slice (first incomplete with deps satisfied) + const doneSliceIds = new Set(activeRoadmap.slices.filter(s => s.done).map(s => s.id)); + let activeSlice: ActiveRef | null = null; + + for (const s of activeRoadmap.slices) { + if (s.done) continue; + if (s.depends.every(dep => doneSliceIds.has(dep))) { + activeSlice = { id: s.id, title: s.title }; + break; + } + } + + if (!activeSlice) { + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: 'blocked', + recentDecisions: [], + blockers: ['No slice eligible — check dependency ordering'], + nextAction: 'Resolve dependency blockers or plan next slice.', + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + + const activeBranch = getActiveSliceBranch(basePath); + + // Check if the slice has a plan + const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN"); + const slicePlanContent = planFile ? await loadFile(planFile) : null; + + if (!slicePlanContent) { + return { + activeMilestone, + activeSlice, + activeTask: null, + phase: 'planning', + recentDecisions: [], + blockers: [], + nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, + activeBranch: activeBranch ?? undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + + const slicePlan = parsePlan(slicePlanContent); + const taskProgress = { + done: slicePlan.tasks.filter(t => t.done).length, + total: slicePlan.tasks.length, + }; + const activeTaskEntry = slicePlan.tasks.find(t => !t.done); + + if (!activeTaskEntry) { + // All tasks done but slice not marked complete + return { + activeMilestone, + activeSlice, + activeTask: null, + phase: 'summarizing', + recentDecisions: [], + blockers: [], + nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`, + activeBranch: activeBranch ?? undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + + const activeTask: ActiveRef = { + id: activeTaskEntry.id, + title: activeTaskEntry.title, + }; + + // ── Blocker detection: scan completed task summaries ────────────────── + // If any completed task has blocker_discovered: true and no REPLAN.md + // exists yet, transition to replanning-slice instead of executing. + const completedTasks = slicePlan.tasks.filter(t => t.done); + let blockerTaskId: string | null = null; + for (const ct of completedTasks) { + const summaryFile = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, ct.id, "SUMMARY"); + if (!summaryFile) continue; + const summaryContent = await loadFile(summaryFile); + if (!summaryContent) continue; + const summary = parseSummary(summaryContent); + if (summary.frontmatter.blocker_discovered) { + blockerTaskId = ct.id; + break; + } + } + + if (blockerTaskId) { + // Loop protection: if REPLAN.md already exists, a replan was already + // performed for this slice — skip further replanning and continue executing. + const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN"); + if (!replanFile) { + return { + activeMilestone, + activeSlice, + activeTask, + phase: 'replanning-slice', + recentDecisions: [], + blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`], + nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`, + activeBranch: activeBranch ?? undefined, + activeWorkspace: undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + // REPLAN.md exists — loop protection: fall through to normal executing + } + + // Check for interrupted work + const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); + const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null; + // Also check legacy continue.md + const hasInterrupted = !!(continueFile && await loadFile(continueFile)) || + !!(sDir && await loadFile(join(sDir, "continue.md"))); + + return { + activeMilestone, + activeSlice, + activeTask, + phase: 'executing', + recentDecisions: [], + blockers: [], + nextAction: hasInterrupted + ? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.` + : `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`, + activeBranch: activeBranch ?? undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; +} diff --git a/src/resources/extensions/gsd/templates/context.md b/src/resources/extensions/gsd/templates/context.md new file mode 100644 index 000000000..7b72e443b --- /dev/null +++ b/src/resources/extensions/gsd/templates/context.md @@ -0,0 +1,76 @@ +# {{milestoneId}}: {{milestoneTitle}} — Context + +**Gathered:** {{date}} +**Status:** Ready for planning + +## Project Description + +{{description}} + +## Why This Milestone + +{{whatProblemThisSolves_AND_whyNow}} + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- {{literalUserActionInRealEnvironment}} +- {{literalUserActionInRealEnvironment}} + +### Entry point / environment + +- Entry point: {{CLI command / URL / bot / extension / service / workflow}} +- Environment: {{local dev / browser / mobile / launchd / CI / production-like}} +- Live dependencies involved: {{telegram / database / webhook / rpc subprocess / none}} + +## Completion Class + +- Contract complete means: {{what can be proven by tests / fixtures / artifacts}} +- Integration complete means: {{what must work across real subsystems}} +- Operational complete means: {{what must work under real lifecycle conditions, or none}} + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- {{one real end-to-end scenario}} +- {{one real end-to-end scenario}} +- {{what cannot be simulated if this milestone is to be considered truly done}} + +## Risks and Unknowns + +- {{riskOrUnknown}} — {{whyItMatters}} + +## Existing Codebase / Prior Art + +- `{{fileOrModule}}` — {{howItRelates}} +- `{{fileOrModule}}` — {{howItRelates}} + +> See `.gsd/DECISIONS.md` for all architectural and pattern decisions — it is an append-only register; read it during planning, append to it during execution. + +## Relevant Requirements + +- {{requirementId}} — {{howThisMilestoneAdvancesIt}} + +## Scope + +### In Scope + +- {{inScopeItem}} + +### Out of Scope / Non-Goals + +- {{outOfScopeItem}} + +## Technical Constraints + +- {{constraint}} + +## Integration Points + +- {{systemOrService}} — {{howThisMilestoneInteractsWithIt}} + +## Open Questions + +- {{question}} — {{currentThinking}} diff --git a/src/resources/extensions/gsd/templates/decisions.md b/src/resources/extensions/gsd/templates/decisions.md new file mode 100644 index 000000000..d8e56d1ee --- /dev/null +++ b/src/resources/extensions/gsd/templates/decisions.md @@ -0,0 +1,8 @@ +# Decisions Register + + + +| # | When | Scope | Decision | Choice | Rationale | Revisable? | +|---|------|-------|----------|--------|-----------|------------| diff --git a/src/resources/extensions/gsd/templates/milestone-summary.md b/src/resources/extensions/gsd/templates/milestone-summary.md new file mode 100644 index 000000000..254db7d4c --- /dev/null +++ b/src/resources/extensions/gsd/templates/milestone-summary.md @@ -0,0 +1,73 @@ +--- +id: {{milestoneId}} +provides: + - {{whatThisMilestoneProvides}} +key_decisions: + - {{decision}} +patterns_established: + - {{pattern}} +observability_surfaces: + - {{status endpoint, structured log, persisted failure state, diagnostic command, or none}} +requirement_outcomes: + - id: {{requirementId}} + from_status: {{active|blocked|deferred}} + to_status: {{validated|deferred|blocked|out_of_scope}} + proof: {{whatEvidenceSupportsThisTransition}} +duration: {{duration}} +verification_result: passed +completed_at: {{date}} +--- + +# {{milestoneId}}: {{milestoneTitle}} + + + +**{{oneLiner}}** + +## What Happened + + + +{{crossSliceNarrative}} + +## Cross-Slice Verification + + + +{{howSuccessCriteriaWereVerified}} + +## Requirement Changes + + + +- {{requirementId}}: {{fromStatus}} → {{toStatus}} — {{evidence}} + +## Forward Intelligence + + + +### What the next milestone should know +- {{insightThatWouldHelpDownstreamWork}} + +### What's fragile +- {{fragileAreaOrThinImplementation}} — {{whyItMatters}} + +### Authoritative diagnostics +- {{whereAFutureAgentShouldLookFirst}} — {{whyThisSignalIsTrustworthy}} + +### What assumptions changed +- {{originalAssumption}} — {{whatActuallyHappened}} + +## Files Created/Modified + +- `{{filePath}}` — {{description}} +- `{{filePath}}` — {{description}} diff --git a/src/resources/extensions/gsd/templates/plan.md b/src/resources/extensions/gsd/templates/plan.md new file mode 100644 index 000000000..ab64b7908 --- /dev/null +++ b/src/resources/extensions/gsd/templates/plan.md @@ -0,0 +1,133 @@ +# {{sliceId}}: {{sliceTitle}} + +**Goal:** {{goal}} +**Demo:** {{demo}} + +## Must-Haves + +- {{mustHave}} +- {{mustHave}} + +## Proof Level + +- This slice proves: {{contract | integration | operational | final-assembly}} +- Real runtime required: {{yes/no}} +- Human/UAT required: {{yes/no}} + +## Verification + + + +- {{testFileOrCommand — e.g. `npm test -- --grep "auth flow"` or `bash scripts/verify-s01.sh`}} +- {{testFileOrCommand}} + +## Observability / Diagnostics + + + +- Runtime signals: {{structured log/event, state transition, metric, or none}} +- Inspection surfaces: {{status endpoint, CLI command, script, UI state, DB table, or none}} +- Failure visibility: {{last error, retry count, phase, timestamp, correlation id, or none}} +- Redaction constraints: {{secret/PII boundary or none}} + +## Integration Closure + +- Upstream surfaces consumed: {{specific files / modules / contracts}} +- New wiring introduced in this slice: {{entrypoint / composition / runtime hookup, or none}} +- What remains before the milestone is truly usable end-to-end: {{list or "nothing"}} + +## Tasks + + + +- [ ] **T01: {{taskTitle}}** `est:{{estimate}}` + - Why: {{whyThisTaskExists}} + - Files: `{{filePath}}`, `{{filePath}}` + - Do: {{specificImplementationStepsAndConstraints}} + - Verify: {{testCommandOrRuntimeCheck}} + - Done when: {{measurableAcceptanceCondition}} +- [ ] **T02: {{taskTitle}}** `est:{{estimate}}` + - Why: {{whyThisTaskExists}} + - Files: `{{filePath}}`, `{{filePath}}` + - Do: {{specificImplementationStepsAndConstraints}} + - Verify: {{testCommandOrRuntimeCheck}} + - Done when: {{measurableAcceptanceCondition}} +- [ ] **T03: {{taskTitle}}** `est:{{estimate}}` + - Why: {{whyThisTaskExists}} + - Files: `{{filePath}}`, `{{filePath}}` + - Do: {{specificImplementationStepsAndConstraints}} + - Verify: {{testCommandOrRuntimeCheck}} + - Done when: {{measurableAcceptanceCondition}} + + + +## Files Likely Touched + +- `{{filePath}}` +- `{{filePath}}` diff --git a/src/resources/extensions/gsd/templates/preferences.md b/src/resources/extensions/gsd/templates/preferences.md new file mode 100644 index 000000000..1819e0138 --- /dev/null +++ b/src/resources/extensions/gsd/templates/preferences.md @@ -0,0 +1,15 @@ +--- +version: 1 +always_use_skills: [] +prefer_skills: [] +avoid_skills: [] +skill_rules: [] +custom_instructions: [] +models: {} +skill_discovery: +auto_supervisor: {} +--- + +# GSD Skill Preferences + +See `~/.pi/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples. diff --git a/src/resources/extensions/gsd/templates/project.md b/src/resources/extensions/gsd/templates/project.md new file mode 100644 index 000000000..381a85ffb --- /dev/null +++ b/src/resources/extensions/gsd/templates/project.md @@ -0,0 +1,31 @@ +# Project + +## What This Is + +{{whatTheProjectDoes — plain language, current state, not aspirational}} + +## Core Value + + + +{{theOneThingThatMustWorkEvenIfEverythingElseIsCut}} + +## Current State + +{{whatHasBeenBuiltSoFar — what works, what exists, what's deployed}} + +## Architecture / Key Patterns + +{{howItsStructured — conventions, tech stack, key modules, established patterns}} + +## Capability Contract + +See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement status, and coverage mapping. + +## Milestone Sequence + + + +- [ ] M001: {{title}} — {{oneLiner}} +- [ ] M002: {{title}} — {{oneLiner}} diff --git a/src/resources/extensions/gsd/templates/reassessment.md b/src/resources/extensions/gsd/templates/reassessment.md new file mode 100644 index 000000000..1418e506b --- /dev/null +++ b/src/resources/extensions/gsd/templates/reassessment.md @@ -0,0 +1,28 @@ +--- +date: {{YYYY-MM-DD}} +triggering_slice: {{milestoneId/sliceId}} +verdict: {{no-change | modified}} +--- + +# Reassessment: {{triggering_slice}} + +## Changes Made + + + +{{placeholder}} + +## Requirement Coverage Impact + + + +{{placeholder}} + +## Decision References + + + +{{placeholder}} diff --git a/src/resources/extensions/gsd/templates/requirements.md b/src/resources/extensions/gsd/templates/requirements.md new file mode 100644 index 000000000..72cf7b82f --- /dev/null +++ b/src/resources/extensions/gsd/templates/requirements.md @@ -0,0 +1,81 @@ +# Requirements + +This file is the explicit capability and coverage contract for the project. + +Use it to track what is actively in scope, what has been validated by completed work, what is intentionally deferred, and what is explicitly out of scope. + +Guidelines: +- Keep requirements capability-oriented, not a giant feature wishlist. +- Requirements should be atomic, testable, and stated in plain language. +- Every **Active** requirement should be mapped to a slice, deferred, blocked with reason, or moved out of scope. +- Each requirement should have one accountable primary owner and may have supporting slices. +- Research may suggest requirements, but research does not silently make them binding. +- Validation means the requirement was actually proven by completed work and verification, not just discussed. + +## Active + +### R001 — {{requirementTitle}} +- Class: {{core-capability | primary-user-loop | launchability | continuity | failure-visibility | integration | quality-attribute | operability | admin/support | compliance/security | differentiator | constraint | anti-feature}} +- Status: active +- Description: {{what must be true in plain language}} +- Why it matters: {{why this matters to actual product usefulness/completeness}} +- Source: {{user | inferred | research | execution}} +- Primary owning slice: {{M001/S01 | none yet}} +- Supporting slices: {{M001/S02, M001/S03 | none}} +- Validation: {{unmapped | mapped | partial | validated}} +- Notes: {{constraints / acceptance nuance / why not yet validated}} + +## Validated + +### R010 — {{requirementTitle}} +- Class: {{failure-visibility}} +- Status: validated +- Description: {{what was proven}} +- Why it matters: {{why it matters}} +- Source: {{user | inferred | research | execution}} +- Primary owning slice: {{M001/S01}} +- Supporting slices: {{none}} +- Validation: validated +- Notes: {{what verification proved this}} + +## Deferred + +### R020 — {{requirementTitle}} +- Class: {{admin/support}} +- Status: deferred +- Description: {{useful later, not now}} +- Why it matters: {{why it might matter later}} +- Source: {{user | inferred | research | execution}} +- Primary owning slice: {{none}} +- Supporting slices: {{none}} +- Validation: unmapped +- Notes: {{why deferred now}} + +## Out of Scope + +### R030 — {{requirementTitle}} +- Class: {{anti-feature | constraint | core-capability}} +- Status: out-of-scope +- Description: {{what is explicitly excluded}} +- Why it matters: {{what scope confusion this prevents}} +- Source: {{user | inferred | research | execution}} +- Primary owning slice: {{none}} +- Supporting slices: {{none}} +- Validation: n/a +- Notes: {{why excluded}} + +## Traceability + +| ID | Class | Status | Primary owner | Supporting | Proof | +|---|---|---|---|---|---| +| R001 | primary-user-loop | active | M001/S01 | none | mapped | +| R010 | failure-visibility | validated | M001/S01 | none | validated | +| R020 | admin/support | deferred | none | none | unmapped | +| R030 | anti-feature | out-of-scope | none | none | n/a | + +## Coverage Summary + +- Active requirements: {{count}} +- Mapped to slices: {{count}} +- Validated: {{count}} +- Unmapped active requirements: {{count}} diff --git a/src/resources/extensions/gsd/templates/research.md b/src/resources/extensions/gsd/templates/research.md new file mode 100644 index 000000000..8f0d65816 --- /dev/null +++ b/src/resources/extensions/gsd/templates/research.md @@ -0,0 +1,46 @@ +# {{scope}} — Research + +**Date:** {{date}} + +## Summary + +{{summary — 2-3 paragraphs with primary recommendation}} + +## Recommendation + +{{whatApproachToTake_AND_why}} + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| {{problem}} | {{solution}} | {{why}} | + +## Existing Code and Patterns + +- `{{filePath}}` — {{whatItDoesAndHowToReuseIt}} +- `{{filePath}}` — {{patternToFollowOrAvoid}} + +## Constraints + +- {{hardConstraintFromCodebaseOrRuntime}} +- {{constraintFromDependencies}} + +## Common Pitfalls + +- **{{pitfall}}** — {{howToAvoid}} +- **{{pitfall}}** — {{howToAvoid}} + +## Open Risks + +- {{riskThatCouldSurfaceDuringExecution}} + +## Skills Discovered + +| Technology | Skill | Status | +|------------|-------|--------| +| {{technology}} | {{owner/repo@skill}} | {{installed / available / none found}} | + +## Sources + +- {{whatWasLearned}} (source: [{{title}}]({{url}})) diff --git a/src/resources/extensions/gsd/templates/roadmap.md b/src/resources/extensions/gsd/templates/roadmap.md new file mode 100644 index 000000000..4fbb7d79d --- /dev/null +++ b/src/resources/extensions/gsd/templates/roadmap.md @@ -0,0 +1,118 @@ +# {{milestoneId}}: {{milestoneTitle}} + +**Vision:** {{vision}} + +## Success Criteria + + + +- {{criterion}} +- {{criterion}} + +## Key Risks / Unknowns + + + +- {{risk}} — {{whyItMatters}} +- {{risk}} — {{whyItMatters}} + +## Proof Strategy + + + +- {{riskOrUnknown}} → retire in {{sliceId}} by proving {{whatWillBeProven}} +- {{riskOrUnknown}} → retire in {{sliceId}} by proving {{whatWillBeProven}} + +## Verification Classes + +- Contract verification: {{tests / shell verifiers / fixtures / artifact checks}} +- Integration verification: {{real subsystem interaction that must be exercised, or none}} +- Operational verification: {{service lifecycle / restart / reconnect / supervision / deploy-install behavior, or none}} +- UAT / human verification: {{what needs real human judgment, or none}} + +## Milestone Definition of Done + +This milestone is complete only when all are true: + +- {{all slice deliverables are complete}} +- {{shared components are actually wired together}} +- {{the real entrypoint exists and is exercised}} +- {{success criteria are re-checked against live behavior, not just artifacts}} +- {{final integrated acceptance scenarios pass}} + +## Requirement Coverage + +- Covers: {{R001, R002}} +- Partially covers: {{R003 or none}} +- Leaves for later: {{R004 or none}} +- Orphan risks: {{none or what is still unmapped}} + +## Slices + +- [ ] **S01: {{sliceTitle}}** `risk:high` `depends:[]` + > After this: {{whatIsDemoableWhenThisSliceIsDone}} +- [ ] **S02: {{sliceTitle}}** `risk:medium` `depends:[S01]` + > After this: {{whatIsDemoableWhenThisSliceIsDone}} +- [ ] **S03: {{sliceTitle}}** `risk:low` `depends:[S01]` + > After this: {{whatIsDemoableWhenThisSliceIsDone}} + + + +## Boundary Map + + + +### S01 → S02 + +Produces: +- {{concreteOutput — API, type, data shape, interface, or invariant}} + +Consumes: +- nothing (first slice) + +### S01 → S03 + +Produces: +- {{concreteOutput — API, type, data shape, interface, or invariant}} + +Consumes: +- nothing (first slice) diff --git a/src/resources/extensions/gsd/templates/slice-context.md b/src/resources/extensions/gsd/templates/slice-context.md new file mode 100644 index 000000000..b87737021 --- /dev/null +++ b/src/resources/extensions/gsd/templates/slice-context.md @@ -0,0 +1,58 @@ +--- +id: {{sliceId}} +milestone: {{milestoneId}} +status: {{draft|ready|in_progress|complete}} +--- + +# {{sliceId}}: {{sliceTitle}} — Context + + + +## Goal + + + +{{sliceGoal}} + +## Why this Slice + + + +{{whyNowAndWhatItUnblocks}} + +## Scope + + + +### In Scope + +- {{inScopeItem}} + +### Out of Scope + +- {{outOfScopeItem}} + +## Constraints + + + +- {{constraint}} + +## Integration Points + + + +### Consumes + +- `{{fileOrArtifact}}` — {{howItIsUsed}} + +### Produces + +- `{{fileOrArtifact}}` — {{whatItProvides}} + +## Open Questions + + + +- {{question}} — {{currentThinking}} diff --git a/src/resources/extensions/gsd/templates/slice-summary.md b/src/resources/extensions/gsd/templates/slice-summary.md new file mode 100644 index 000000000..3b7851eb5 --- /dev/null +++ b/src/resources/extensions/gsd/templates/slice-summary.md @@ -0,0 +1,99 @@ +--- +id: {{sliceId}} +parent: {{milestoneId}} +milestone: {{milestoneId}} +provides: + - {{whatThisSliceProvides}} +requires: + - slice: {{depSliceId}} + provides: {{whatWasConsumed}} +affects: + - {{downstreamSliceId}} +key_files: + - {{filePath}} +key_decisions: + - {{decision}} +patterns_established: + - {{pattern}} +observability_surfaces: + - {{status endpoint, structured log, persisted failure state, diagnostic command, or none}} +drill_down_paths: + - {{pathToTaskSummary}} +duration: {{duration}} +verification_result: passed +completed_at: {{date}} +--- + +# {{sliceId}}: {{sliceTitle}} + + + +**{{oneLiner}}** + +## What Happened + +{{narrative — compress task summaries into a coherent story}} + +## Verification + +{{whatWasVerifiedAcrossAllTasks — tests, builds, manual checks}} + + +## Requirements Advanced + +- {{requirementId}} — {{howThisSliceAdvancedIt}} + +## Requirements Validated + +- {{requirementId}} — {{whatProofNowMakesItValidated}} + +## New Requirements Surfaced + +- {{newRequirementOr_none}} + +## Requirements Invalidated or Re-scoped + +- {{requirementIdOr_none}} — {{what changed}} + +## Deviations + + + +{{deviationsFromPlan_OR_none}} + +## Known Limitations + + + +{{whatDoesntWorkYet_OR_whatWasDeferredToLaterSlices}} + +## Follow-ups + + + +{{workDeferredOrDiscoveredDuringExecution_OR_none}} + +## Files Created/Modified + +- `{{filePath}}` — {{description}} +- `{{filePath}}` — {{description}} + +## Forward Intelligence + + + +### What the next slice should know +- {{insightThatWouldHelpDownstreamWork}} + +### What's fragile +- {{fragileAreaOrThinImplementation}} — {{whyItMatters}} + +### Authoritative diagnostics +- {{whereAFutureAgentShouldLookFirst}} — {{whyThisSignalIsTrustworthy}} + +### What assumptions changed +- {{originalAssumption}} — {{whatActuallyHappened}} diff --git a/src/resources/extensions/gsd/templates/state.md b/src/resources/extensions/gsd/templates/state.md new file mode 100644 index 000000000..2279f79fe --- /dev/null +++ b/src/resources/extensions/gsd/templates/state.md @@ -0,0 +1,19 @@ +# GSD State + +**Active Milestone:** {{milestoneId}} — {{milestoneTitle}} +**Active Slice:** {{sliceId}} — {{sliceTitle}} +**Active Task:** {{taskId}} — {{taskTitle}} +**Phase:** {{phase}} +**Slice Branch:** {{activeBranch}} +**Active Workspace:** {{activeWorkspace}} +**Next Action:** {{nextAction}} +**Last Updated:** {{date}} +**Requirements Status:** {{activeCount}} active · {{validatedCount}} validated · {{deferredCount}} deferred · {{outOfScopeCount}} out of scope + +## Recent Decisions + +- {{decision}} + +## Blockers + +- (none) diff --git a/src/resources/extensions/gsd/templates/task-plan.md b/src/resources/extensions/gsd/templates/task-plan.md new file mode 100644 index 000000000..db886e087 --- /dev/null +++ b/src/resources/extensions/gsd/templates/task-plan.md @@ -0,0 +1,52 @@ +--- +# Optional scope estimate — helps the plan quality validator detect over-scoped tasks. +# Tasks with 10+ estimated steps or 12+ estimated files trigger a warning to consider splitting. +estimated_steps: {{estimatedSteps}} +estimated_files: {{estimatedFiles}} +--- + +# {{taskId}}: {{taskTitle}} + +**Slice:** {{sliceId}} — {{sliceTitle}} +**Milestone:** {{milestoneId}} + +## Description + +{{description}} + +## Steps + +1. {{step}} +2. {{step}} +3. {{step}} + +## Must-Haves + +- [ ] {{mustHave}} +- [ ] {{mustHave}} + +## Verification + +- {{howToVerifyThisTaskIsActuallyDone}} +- {{commandToRun_OR_behaviorToCheck}} + +## Observability Impact + + + +- Signals added/changed: {{structured logs, statuses, errors, metrics, or None}} +- How a future agent inspects this: {{command, endpoint, file, UI state, or None}} +- Failure state exposed: {{what becomes visible on failure, or None}} + +## Inputs + +- `{{filePath}}` — {{whatThisTaskNeedsFromPriorWork}} +- {{priorTaskSummaryInsight}} + +## Expected Output + + + +- `{{filePath}}` — {{whatThisTaskShouldProduceOrModify}} diff --git a/src/resources/extensions/gsd/templates/task-summary.md b/src/resources/extensions/gsd/templates/task-summary.md new file mode 100644 index 000000000..1f7f6c719 --- /dev/null +++ b/src/resources/extensions/gsd/templates/task-summary.md @@ -0,0 +1,57 @@ +--- +id: {{taskId}} +parent: {{sliceId}} +milestone: {{milestoneId}} +provides: + - {{whatThisTaskProvides}} +key_files: + - {{filePath}} +key_decisions: + - {{decision}} +patterns_established: + - {{pattern}} +observability_surfaces: + - {{status endpoint, structured log, persisted failure state, diagnostic command, or none}} +duration: {{duration}} +verification_result: passed +completed_at: {{date}} +# Set blocker_discovered: true only if execution revealed the remaining slice plan +# is fundamentally invalid (wrong API, missing capability, architectural mismatch). +# Do NOT set true for ordinary bugs, minor deviations, or fixable issues. +blocker_discovered: false +--- + +# {{taskId}}: {{taskTitle}} + + + +**{{oneLiner}}** + +## What Happened + +{{narrative}} + +## Verification + +{{whatWasVerifiedAndHow — commands run, tests passed, behavior confirmed}} + +## Diagnostics + +{{howToInspectWhatThisTaskBuiltLater — status surfaces, logs, error shapes, failure artifacts, or none}} + +## Deviations + + + +{{deviationsFromPlan_OR_none}} + +## Known Issues + +{{issuesDiscoveredButNotFixed_OR_none}} + +## Files Created/Modified + +- `{{filePath}}` — {{description}} +- `{{filePath}}` — {{description}} diff --git a/src/resources/extensions/gsd/templates/uat.md b/src/resources/extensions/gsd/templates/uat.md new file mode 100644 index 000000000..18e5b0e39 --- /dev/null +++ b/src/resources/extensions/gsd/templates/uat.md @@ -0,0 +1,54 @@ +# {{sliceId}}: {{sliceTitle}} — UAT + +**Milestone:** {{milestoneId}} +**Written:** {{date}} + +## UAT Type + +- UAT mode: {{artifact-driven | live-runtime | human-experience | mixed}} +- Why this mode is sufficient: {{reason}} + +## Preconditions + +{{whatMustBeTrueBeforeTesting — server running, data seeded, etc.}} + +## Smoke Test + +{{oneQuickCheckThatConfirmsTheSliceBasicallyWorks}} + +## Test Cases + +### 1. {{testName}} + +1. {{step}} +2. {{step}} +3. **Expected:** {{expected}} + +### 2. {{testName}} + +1. {{step}} +2. **Expected:** {{expected}} + +## Edge Cases + +### {{edgeCaseName}} + +1. {{step}} +2. **Expected:** {{expected}} + +## Failure Signals + +- {{whatWouldIndicateSomethingIsBroken — errors, missing UI, wrong data}} + +## Requirements Proved By This UAT + +- {{requirementIdOr_none}} — {{what this UAT proves}} + +## Not Proven By This UAT + +- {{what this UAT intentionally does not prove}} +- {{remaining live/runtime/operational gaps, if any}} + +## Notes for Tester + +{{anythingTheHumanShouldKnow — known rough edges, things to ignore, areas needing gut check}} diff --git a/src/resources/extensions/gsd/tests/activity-log-prune.test.ts b/src/resources/extensions/gsd/tests/activity-log-prune.test.ts new file mode 100644 index 000000000..4b6e307c1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/activity-log-prune.test.ts @@ -0,0 +1,327 @@ +// Tests for pruneActivityLogs — age-based activity log pruning with +// highest-seq preservation invariant — plus step-11 prompt text assertion. +// +// Sections: +// (a) Basic pruning: one old file deleted, two recent survive +// (b) Highest-seq preserved even when all files are old +// (c) retentionDays=0 boundary: all non-highest-seq deleted +// (d) No-op when all files are recent +// (e) Empty directory: no crash +// (f) All old files: only highest-seq survives +// (g) Single file: always preserved (it IS highest-seq) +// (h) Seq number is tie-breaker (010 beats 001 lexicographically and numerically) +// (i) Non-matching filenames ignored: notes.txt survives, no crash +// (j) Step-11 prompt text: "refresh current state if needed" + +import { mkdtempSync, mkdirSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { pruneActivityLogs } from '../activity-log.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ─── Assertion helpers ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture helpers ─────────────────────────────────────────────────────── + +let tmpDirs: string[] = []; + +function createTmpActivityDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'gsd-prune-test-')); + tmpDirs.push(dir); + return dir; +} + +function writeActivityFile(activityDir: string, seq: string, name: string): string { + mkdirSync(activityDir, { recursive: true }); + const filePath = join(activityDir, `${seq}-${name}.jsonl`); + writeFileSync(filePath, `{"seq":${parseInt(seq, 10)},"name":"${name}"}\n`, 'utf-8'); + return filePath; +} + +/** Set mtime to daysAgo days in the past. */ +function backdateFile(filePath: string, daysAgo: number): void { + const pastMs = Date.now() - daysAgo * 24 * 60 * 60 * 1000; + const pastDate = new Date(pastMs); + utimesSync(filePath, pastDate, pastDate); +} + +function cleanup(): void { + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tmpDirs = []; +} + +process.on('exit', cleanup); + +// ─── Helper: get sorted filenames (basenames only) in a directory ────────── + +function listFiles(dir: string): string[] { + return readdirSync(dir).sort(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── (a) Basic pruning ──────────────────────────────────────────────────── + console.log('\n── (a) Basic pruning: one old file deleted, two recent survive'); + + { + const dir = createTmpActivityDir(); + const f001 = writeActivityFile(dir, '001', 'execute-task-M001-S01-T01'); + const _f002 = writeActivityFile(dir, '002', 'execute-task-M001-S01-T02'); + const _f003 = writeActivityFile(dir, '003', 'execute-task-M001-S01-T03'); + + backdateFile(f001, 40); // older than 30-day retention + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assert( + !remaining.includes('001-execute-task-M001-S01-T01.jsonl'), + '(a) file 001 deleted (40 days old, past 30-day threshold)', + ); + assert( + remaining.includes('002-execute-task-M001-S01-T02.jsonl'), + '(a) file 002 survives (recent)', + ); + assert( + remaining.includes('003-execute-task-M001-S01-T03.jsonl'), + '(a) file 003 survives (recent, also highest-seq)', + ); + } + + // ─── (b) Highest-seq preserved even when all files are old ─────────────── + console.log('\n── (b) Highest-seq preserved even when all files are old'); + + { + const dir = createTmpActivityDir(); + const f001 = writeActivityFile(dir, '001', 'execute-task-M001-S01-T01'); + const f002 = writeActivityFile(dir, '002', 'execute-task-M001-S01-T02'); + const f003 = writeActivityFile(dir, '003', 'execute-task-M001-S01-T03'); + + backdateFile(f001, 40); + backdateFile(f002, 40); + backdateFile(f003, 40); // all old, but 003 is highest-seq + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assertEq(remaining.length, 1, '(b) exactly 1 file survives when all are old'); + assert( + remaining.includes('003-execute-task-M001-S01-T03.jsonl'), + '(b) highest-seq file (003) is the survivor', + ); + } + + // ─── (c) retentionDays=0 boundary ──────────────────────────────────────── + console.log('\n── (c) retentionDays=0: all non-highest-seq deleted even if brand-new'); + + { + const dir = createTmpActivityDir(); + // All files have mtime=now (freshly written — no backdating) + writeActivityFile(dir, '001', 'execute-task-M002-S01-T01'); + writeActivityFile(dir, '002', 'execute-task-M002-S01-T02'); + writeActivityFile(dir, '003', 'execute-task-M002-S01-T03'); + + pruneActivityLogs(dir, 0); // cutoff = now → everything is "expired" + + const remaining = listFiles(dir); + assertEq(remaining.length, 1, '(c) retentionDays=0: exactly 1 file survives'); + assert( + remaining.includes('003-execute-task-M002-S01-T03.jsonl'), + '(c) retentionDays=0: only highest-seq (003) survives', + ); + } + + // ─── (d) No-op when all files are recent ───────────────────────────────── + console.log('\n── (d) No-op when all files are recent'); + + { + const dir = createTmpActivityDir(); + writeActivityFile(dir, '001', 'execute-task-M003-S01-T01'); + writeActivityFile(dir, '002', 'execute-task-M003-S01-T02'); + writeActivityFile(dir, '003', 'execute-task-M003-S01-T03'); + // No backdating — all files are fresh + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assertEq(remaining.length, 3, '(d) all 3 files survive when all are recent'); + } + + // ─── (e) Empty directory: no crash ──────────────────────────────────────── + console.log('\n── (e) Empty directory: no crash'); + + { + const dir = createTmpActivityDir(); + // dir exists but is empty + + let threw = false; + try { + pruneActivityLogs(dir, 30); + } catch { + threw = true; + } + + assert(!threw, '(e) pruneActivityLogs does not throw on empty directory'); + assert( + readdirSync(dir).length === 0, + '(e) directory still exists and is still empty after no-op', + ); + } + + // ─── (f) All old files: only highest-seq survives ───────────────────────── + console.log('\n── (f) All old files: only highest-seq survives'); + + { + const dir = createTmpActivityDir(); + const f004 = writeActivityFile(dir, '004', 'execute-task-M004-S01-T01'); + const f005 = writeActivityFile(dir, '005', 'execute-task-M004-S01-T02'); + const f006 = writeActivityFile(dir, '006', 'execute-task-M004-S01-T03'); + + backdateFile(f004, 60); + backdateFile(f005, 60); + backdateFile(f006, 60); + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assertEq(remaining.length, 1, '(f) exactly 1 file survives when all are old'); + assert( + remaining[0].startsWith('006-'), + '(f) the surviving file starts with 006 (highest-seq)', + ); + } + + // ─── (g) Single file: always preserved ──────────────────────────────────── + console.log('\n── (g) Single file: always preserved (it IS highest-seq)'); + + { + const dir = createTmpActivityDir(); + const f001 = writeActivityFile(dir, '001', 'execute-task-M005-S01-T01'); + backdateFile(f001, 100); // very old + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assertEq(remaining.length, 1, '(g) single file survives even when very old (it is the highest-seq)'); + assert( + remaining.includes('001-execute-task-M005-S01-T01.jsonl'), + '(g) the single file (001) is preserved', + ); + } + + // ─── (h) Seq tie-breaker: 010 is higher than 001 ───────────────────────── + console.log('\n── (h) Seq number tie-breaker: 010 beats 001 numerically'); + + { + const dir = createTmpActivityDir(); + const f001 = writeActivityFile(dir, '001', 'execute-task-M006-S01-T01'); + const f010 = writeActivityFile(dir, '010', 'execute-task-M006-S01-T10'); + + backdateFile(f001, 40); + backdateFile(f010, 40); // both old; 010 is numerically highest + + pruneActivityLogs(dir, 30); + + const remaining = listFiles(dir); + assertEq(remaining.length, 1, '(h) exactly 1 file survives'); + assert( + remaining.includes('010-execute-task-M006-S01-T10.jsonl'), + '(h) seq 010 (numeric 10) survives over seq 001 (numeric 1)', + ); + } + + // ─── (i) Non-matching filenames ignored ─────────────────────────────────── + console.log('\n── (i) Non-matching filenames ignored: notes.txt survives, no crash'); + + { + const dir = createTmpActivityDir(); + const f001 = writeActivityFile(dir, '001', 'execute-task-M007-S01-T01'); + const notesPath = join(dir, 'notes.txt'); + writeFileSync(notesPath, 'some notes\n', 'utf-8'); + + backdateFile(f001, 40); // eligible for pruning + // notes.txt never gets a seq prefix → should be ignored by pruner + + let threw = false; + try { + pruneActivityLogs(dir, 30); + } catch { + threw = true; + } + + assert(!threw, '(i) no crash when non-matching file is present'); + + const remaining = listFiles(dir); + assert( + remaining.includes('notes.txt'), + '(i) notes.txt (non-matching filename) survives pruning unchanged', + ); + // 001 is deleted (old, and notes.txt is not counted as seq-bearing so 001 is not "highest") + // But wait — 001 IS the only seq file, making it highest-seq → it survives + assert( + remaining.includes('001-execute-task-M007-S01-T01.jsonl'), + '(i) seq 001 survives (it is the highest-seq among seq files)', + ); + } + + // ─── (j) Step-11 prompt text assertion ──────────────────────────────────── + console.log('\n── (j) Step-11 prompt text: "refresh current state if needed"'); + + { + const { readFileSync } = await import('node:fs'); + const promptPath = join(__dirname, '..', 'prompts', 'complete-slice.md'); + const content = readFileSync(promptPath, 'utf-8'); + + assert( + content.includes('refresh current state if needed'), + '(j) complete-slice.md step 11 contains "refresh current state if needed"', + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Results + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed ✓'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/auto-preflight.test.ts b/src/resources/extensions/gsd/tests/auto-preflight.test.ts new file mode 100644 index 000000000..e62741a63 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-preflight.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { runGSDDoctor, selectDoctorScope, filterDoctorIssues } from "../doctor.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-")); +const gsd = join(tmpBase, ".gsd"); + +mkdirSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); +mkdirSync(join(gsd, "milestones", "M009", "slices", "S01", "tasks"), { recursive: true }); + +writeFileSync(join(gsd, "milestones", "M001", "M001-ROADMAP.md"), `# M001: Historical\n\n## Slices\n- [x] **S01: Old Slice** \`risk:low\` \`depends:[]\`\n > After this: old done\n`); +writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `# S01: Old Slice\n\n**Goal:** Old\n**Demo:** Old\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Old Task** \`est:5m\`\n done\n`); +writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# T01: Old Task\n\n**Done**\n\n## What Happened\nDone.\n\n## Diagnostics\n- log\n`); +writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"), `---\nid: S01\nparent: M001\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# S01: Old Slice\n\n**Done**\n\n## What Happened\nDone.\n\n## Verification\nDone.\n\n## Deviations\nNone\n\n## Known Limitations\nNone\n\n## Follow-ups\nNone\n\n## Files Created/Modified\n- \`x\` — x\n\n## Forward Intelligence\n\n### What the next slice should know\n- x\n\n### What's fragile\n- x\n\n### Authoritative diagnostics\n- x\n\n### What assumptions changed\n- x\n`); + +writeFileSync(join(gsd, "milestones", "M001", "M001-SUMMARY.md"), `---\nid: M001\nstatus: complete\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# M001: Historical\n\nComplete.\n`); + +writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`); +writeFileSync(join(gsd, "milestones", "M009", "slices", "S01", "S01-PLAN.md"), `# S01: Active Slice\n\n**Goal:** Active\n**Demo:** Active\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Active Task** \`est:5m\`\n todo\n`); + +async function main(): Promise { + const scope = await selectDoctorScope(tmpBase); + assert(scope === "M009/S01", "active scope selected instead of historical milestone"); + + const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope }); + const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false }); + assert(scopedBlocking.length === 0, "no blocking issues in active scope"); + + const historicalReport = await runGSDDoctor(tmpBase, { fix: false }); + const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning"); + assert(historicalWarnings.length > 0, "full repo still contains historical warning drift"); + + rmSync(tmpBase, { recursive: true, force: true }); + + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs b/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs new file mode 100644 index 000000000..e4ba62e18 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeUnitRuntimeRecord, readUnitRuntimeRecord } from '../unit-runtime.ts'; +import { resolveAutoSupervisorConfig } from '../preferences.ts'; + +test('resolveAutoSupervisorConfig provides safe timeout defaults', () => { + const supervisor = resolveAutoSupervisorConfig(); + assert.equal(supervisor.soft_timeout_minutes, 20); + assert.equal(supervisor.idle_timeout_minutes, 10); + assert.equal(supervisor.hard_timeout_minutes, 30); +}); + +test('writeUnitRuntimeRecord persists progress and recovery metadata defaults', () => { + const base = mkdtempSync(join(tmpdir(), 'gsd-auto-supervisor-')); + const startedAt = 1234567890; + + writeUnitRuntimeRecord(base, 'plan-milestone', 'M010', startedAt, { + phase: 'dispatched', + lastProgressAt: startedAt, + progressCount: 1, + lastProgressKind: 'dispatch', + }); + + const runtime = readUnitRuntimeRecord(base, 'plan-milestone', 'M010'); + assert.ok(runtime); + assert.equal(runtime.phase, 'dispatched'); + assert.equal(runtime.lastProgressAt, startedAt); + assert.equal(runtime.progressCount, 1); + assert.equal(runtime.lastProgressKind, 'dispatch'); + assert.equal(runtime.recoveryAttempts, 0); +}); + +test('writeUnitRuntimeRecord keeps explicit recovery attempt fields', () => { + const base = mkdtempSync(join(tmpdir(), 'gsd-auto-supervisor-')); + const startedAt = 2234567890; + + writeUnitRuntimeRecord(base, 'research-milestone', 'M011', startedAt, { + phase: 'timeout', + recoveryAttempts: 2, + lastRecoveryReason: 'idle', + lastProgressAt: startedAt + 50, + progressCount: 3, + lastProgressKind: 'recovery-retry', + }); + + const runtime = JSON.parse(readFileSync(join(base, '.gsd/runtime/units/research-milestone-M011.json'), 'utf8')); + assert.equal(runtime.recoveryAttempts, 2); + assert.equal(runtime.lastRecoveryReason, 'idle'); + assert.equal(runtime.lastProgressKind, 'recovery-retry'); +}); diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts new file mode 100644 index 000000000..7444548ab --- /dev/null +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -0,0 +1,225 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +// loadPrompt reads from ~/.pi/agent/extensions/gsd/prompts/ (main checkout). +// In a worktree the file may not exist there yet, so we resolve prompts +// relative to this test file's location (the worktree copy). +const __dirname = dirname(fileURLToPath(import.meta.url)); +const worktreePromptsDir = join(__dirname, "..", "prompts"); + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +/** + * Load a prompt template from the worktree prompts directory + * and apply variable substitution (mirrors loadPrompt logic). + */ +function loadPromptFromWorktree(name: string, vars: Record = {}): string { + const path = join(worktreePromptsDir, `${name}.md`); + let content = readFileSync(path, "utf-8"); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + return content.trim(); +} + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-complete-ms-test-")); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + return base; +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, ".gsd", "milestones", mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, ".gsd", "milestones", mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Prompt Template Loading ─────────────────────────────────────────── + console.log("\n=== complete-milestone prompt template exists ==="); + { + let result: string; + let threw = false; + try { + result = loadPromptFromWorktree("complete-milestone", { + milestoneId: "M001", + milestoneTitle: "Test Milestone", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedContext: "test context block", + }); + } catch (err) { + threw = true; + result = ""; + console.error(` ERROR: loadPrompt threw: ${err}`); + } + + assert(!threw, "loadPrompt does not throw for complete-milestone"); + assert(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string"); + } + + // ─── Variable Substitution ───────────────────────────────────────────── + console.log("\n=== prompt variable substitution ==="); + { + const prompt = loadPromptFromWorktree("complete-milestone", { + milestoneId: "M001", + milestoneTitle: "Integration Feature", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedContext: "--- inlined slice summaries and context ---", + }); + + assert(prompt.includes("M001"), "prompt contains milestoneId 'M001'"); + assert(prompt.includes("Integration Feature"), "prompt contains milestoneTitle"); + assert(prompt.includes(".gsd/milestones/M001/M001-ROADMAP.md"), "prompt contains roadmapPath"); + assert(prompt.includes("--- inlined slice summaries and context ---"), "prompt contains inlinedContext"); + assert(!prompt.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}"); + assert(!prompt.includes("{{milestoneTitle}}"), "no un-substituted {{milestoneTitle}}"); + assert(!prompt.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}"); + assert(!prompt.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}"); + } + + // ─── Prompt Content Integrity ────────────────────────────────────────── + console.log("\n=== prompt content integrity ==="); + { + const prompt = loadPromptFromWorktree("complete-milestone", { + milestoneId: "M002", + milestoneTitle: "Completion Workflow", + roadmapPath: ".gsd/milestones/M002/M002-ROADMAP.md", + inlinedContext: "context", + }); + + assert(prompt.includes("Complete Milestone"), "prompt contains 'Complete Milestone' heading"); + assert(prompt.includes("success criter") || prompt.includes("success criteria"), "prompt mentions success criteria verification"); + assert(prompt.includes("milestone-summary") || prompt.includes("milestoneSummary"), "prompt references milestone summary artifact"); + assert(prompt.includes("Milestone M002 complete"), "prompt contains completion sentinel for M002"); + } + + // ─── diagnoseExpectedArtifact behavior ───────────────────────────────── + // Since diagnoseExpectedArtifact is not exported from auto.ts, we test + // the same logic by reimplementing the switch case for complete-milestone + // and verifying against known path patterns. + console.log("\n=== diagnoseExpectedArtifact logic for complete-milestone ==="); + { + // Import the path helpers used by diagnoseExpectedArtifact + const { relMilestoneFile } = await import("../paths.ts"); + + // Simulate diagnoseExpectedArtifact("complete-milestone", "M001", base) logic + const base = createFixtureBase(); + try { + writeRoadmap(base, "M001", `# M001\n\n## Slices\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > After this: done\n`); + + const unitType = "complete-milestone"; + const unitId = "M001"; + const parts = unitId.split("/"); + const mid = parts[0]!; + + // This is the exact logic from diagnoseExpectedArtifact for "complete-milestone" + const result = `${relMilestoneFile(base, mid, "SUMMARY")} (milestone summary)`; + + assert(typeof result === "string", "diagnose returns a string"); + assert(result.includes("SUMMARY"), "diagnose result mentions SUMMARY"); + assert(result.includes("milestone"), "diagnose result mentions milestone"); + assert(result.includes("M001"), "diagnose result includes the milestone ID"); + } finally { + cleanup(base); + } + } + + // ─── deriveState integration: completing-milestone dispatches correctly ─ + console.log("\n=== deriveState completing-milestone integration ==="); + { + const { deriveState, isMilestoneComplete } = await import("../state.ts"); + const { parseRoadmap } = await import("../files.ts"); + + const base = createFixtureBase(); + try { + writeRoadmap(base, "M001", `# M001: Integration Test + +**Vision:** Test completing-milestone flow. + +## Slices + +- [x] **S01: Slice One** \`risk:low\` \`depends:[]\` + > After this: done. + +- [x] **S02: Slice Two** \`risk:low\` \`depends:[S01]\` + > After this: done. +`); + + // Verify isMilestoneComplete returns true + const { loadFile } = await import("../files.ts"); + const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + const roadmapContent = await loadFile(roadmapPath); + const roadmap = parseRoadmap(roadmapContent!); + assert(isMilestoneComplete(roadmap), "isMilestoneComplete returns true when all slices are [x]"); + + // Verify deriveState returns completing-milestone phase + const state = await deriveState(base); + assertEq(state.phase, "completing-milestone", "deriveState returns completing-milestone when all slices done, no summary"); + assertEq(state.activeMilestone?.id, "M001", "active milestone is M001"); + assertEq(state.activeSlice, null, "no active slice in completing-milestone"); + + // Now add the summary and verify it transitions to complete + writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); + const stateAfter = await deriveState(base); + assertEq(stateAfter.phase, "complete", "deriveState returns complete after summary exists"); + assertEq(stateAfter.registry[0]?.status, "complete", "registry shows complete status"); + } finally { + cleanup(base); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Results + // ═════════════════════════════════════════════════════════════════════════ + + console.log(`\n${"=".repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log("All tests passed ✓"); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/cost-projection.test.ts b/src/resources/extensions/gsd/tests/cost-projection.test.ts new file mode 100644 index 000000000..32f83a77a --- /dev/null +++ b/src/resources/extensions/gsd/tests/cost-projection.test.ts @@ -0,0 +1,160 @@ +/** + * Contract tests for `formatCostProjection`. + * Tests the pure function — no file I/O, no extension context. + * + * This test intentionally fails at import time (or on first assertion) + * because `formatCostProjection` does not yet exist in metrics.ts. + * That failure confirms the test runs against real code. (T01 state) + */ + +import { + type SliceAggregate, + formatCostProjection, +} from "../metrics.js"; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeSliceAggregate(sliceId: string, cost: number): SliceAggregate { + return { + sliceId, + units: 1, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost, + duration: 1000, + }; +} + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (actual === expected) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── formatCostProjection ───────────────────────────────────────────────────── + +console.log("\n=== formatCostProjection ==="); + +// 1. Zero completed slices → empty result +{ + const result = formatCostProjection([], 3); + assertEq(result.length, 0, "zero slices → empty array"); +} + +// 2. One slice → suppressed (need ≥2 to project reliably) +{ + const result = formatCostProjection([makeSliceAggregate("M001/S01", 0.10)], 3); + assertEq(result.length, 0, "one slice → suppressed (no projection shown)"); +} + +// 3. Two slices → projection shown (result.length > 0) +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5); + assert(result.length > 0, "two slices → projection shown"); +} + +// 4. Two-slice result: result[0] contains "$" (cost is formatted) +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5); + assert(result.length > 0 && result[0].includes("$"), "projection line contains \"$\""); +} + +// 5. Budget ceiling hit: total $0.20 >= ceiling $0.05 → line contains "ceiling" +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5, 0.05); + const hasCeilingLine = result.some( + line => line.toLowerCase().includes("ceiling") + ); + assert(hasCeilingLine, "ceiling warning appears when total ($0.20) >= ceiling ($0.05)"); +} + +// 6. Budget ceiling not hit: total $0.20 < ceiling $100.00 → no ceiling line +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5, 100.00); + const hasCeilingLine = result.some( + line => line.toLowerCase().includes("ceiling") + ); + assert(!hasCeilingLine, "no ceiling warning when total ($0.20) < ceiling ($100.00)"); +} + +// 7. No ceiling arg → no ceiling line +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5); + const hasCeilingLine = result.some( + line => line.toLowerCase().includes("ceiling") + ); + assert(!hasCeilingLine, "no ceiling warning when no ceiling is set"); +} + +// 8. Rounding: avg $0.10 × 5 remaining = $0.50 → result[0] contains "$0.50" +{ + const slices = [ + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 5); + const hasRoundedCost = result.some(line => line.includes("$0.50")); + assert(hasRoundedCost, "projected cost $0.50 (avg $0.10 × 5 remaining) appears in output"); +} + +// 9. Bare milestone entries excluded from average: +// makeSliceAggregate('M001', 5.00) has no "/" in sliceId → excluded from avg calc. +// Only M001/S01 ($0.10) and M001/S02 ($0.10) count → avg $0.10 × 3 remaining = $0.30 +{ + const slices = [ + makeSliceAggregate("M001", 5.00), // bare milestone — must be excluded + makeSliceAggregate("M001/S01", 0.10), + makeSliceAggregate("M001/S02", 0.10), + ]; + const result = formatCostProjection(slices, 3); + const hasCorrectProjection = result.some(line => line.includes("$0.30")); + assert( + hasCorrectProjection, + "bare milestone entry excluded from avg: projection shows $0.30 (avg $0.10 × 3), not $1.83 (including $5.00 entry)" + ); +} + +// ─── Summary ────────────────────────────────────────────────────────────────── + +console.log(`\n${"=".repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + console.error(`${failed} test(s) failed`); + process.exit(1); +} else { + console.log("All tests passed ✓"); +} diff --git a/src/resources/extensions/gsd/tests/derive-state-deps.test.ts b/src/resources/extensions/gsd/tests/derive-state-deps.test.ts new file mode 100644 index 000000000..13b22704a --- /dev/null +++ b/src/resources/extensions/gsd/tests/derive-state-deps.test.ts @@ -0,0 +1,341 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveState } from '../state.ts'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-deps-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +/** + * Creates M00x-CONTEXT.md with a valid YAML frontmatter block. + * frontmatter is the raw YAML lines between the --- delimiters. + */ +function writeContext(base: string, mid: string, frontmatter: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT.md`), `---\n${frontmatter}\n---\n`); +} + +function writeSlicePlan(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(join(dir, 'tasks'), { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Groups +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Test Group 1: blocked-deps ──────────────────────────────────────── + // M001 is incomplete (no SUMMARY), M002 depends_on M001 → M002 is pending + console.log('\n=== blocked-deps ==='); + { + const base = createFixtureBase(); + try { + // M001: incomplete (one slice, no SUMMARY) + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** First milestone still in progress. + +## Slices + +- [ ] **S01: Incomplete Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + // M001: add a slice plan with an active task so phase is 'executing' + writeSlicePlan(base, 'M001', 'S01', `# S01: Incomplete Slice + +**Goal:** Verify dep-blocked milestone behavior. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: Do work** \`est:15m\` + First task still in progress. +`); + + // M002: depends on M001, also incomplete + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Second milestone blocked by M001. + +## Slices + +- [ ] **S01: Blocked Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M002', 'depends_on: [M001]'); + + const state = await deriveState(base); + + assertEq(state.registry[0]?.status, 'active', 'blocked-deps: M001 is active'); + assertEq(state.registry[1]?.status, 'pending', 'blocked-deps: M002 is pending (dep-blocked)'); + assertEq(state.phase, 'executing', 'blocked-deps: phase is executing (M001 is active)'); + assertEq(state.activeMilestone?.id, 'M001', 'blocked-deps: activeMilestone is M001'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 2: unblocked-deps ────────────────────────────────────── + // M001 is complete (all slices [x] + SUMMARY), M002 depends_on M001 → M002 becomes active + console.log('\n=== unblocked-deps ==='); + { + const base = createFixtureBase(); + try { + // M001: complete (all slices done + SUMMARY present) + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** First milestone complete. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone is complete.'); + + // M002: depends on M001, now unblocked + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Second milestone now active. + +## Slices + +- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M002', 'depends_on: [M001]'); + + const state = await deriveState(base); + + assertEq(state.registry[0]?.status, 'complete', 'unblocked-deps: M001 is complete'); + assertEq(state.registry[1]?.status, 'active', 'unblocked-deps: M002 is active'); + assertEq(state.activeMilestone?.id, 'M002', 'unblocked-deps: activeMilestone is M002'); + assert(state.phase !== 'blocked', 'unblocked-deps: phase is not blocked'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 3: all-blocked ───────────────────────────────────────── + // M001 depends_on M002, M002 depends_on M001 — circular dep, neither can activate + console.log('\n=== all-blocked ==='); + { + const base = createFixtureBase(); + try { + // M001: depends on M002 + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** Circular dependency. + +## Slices + +- [ ] **S01: Waiting** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M001', 'depends_on: [M002]'); + + // M002: depends on M001 + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Also in circular dependency. + +## Slices + +- [ ] **S01: Also Waiting** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M002', 'depends_on: [M001]'); + + const state = await deriveState(base); + + assertEq(state.phase, 'blocked', 'all-blocked: phase is blocked'); + assert(state.activeMilestone === null || state.activeMilestone !== null, 'all-blocked: state is consistent'); + assert(state.blockers.length > 0, 'all-blocked: blockers array is non-empty'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 4: absent-context ────────────────────────────────────── + // Neither M001 nor M002 has a CONTEXT.md → no dep constraints, normal sequential behavior + console.log('\n=== absent-context ==='); + { + const base = createFixtureBase(); + try { + // M001: incomplete, no CONTEXT.md + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** No context file, no deps. + +## Slices + +- [ ] **S01: Incomplete** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + // M002: incomplete, no CONTEXT.md + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Also no context file. + +## Slices + +- [ ] **S01: Pending** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + const state = await deriveState(base); + + assertEq(state.registry[0]?.status, 'active', 'absent-context: M001 is active'); + assertEq(state.registry[1]?.status, 'pending', 'absent-context: M002 is pending'); + assertEq(state.activeMilestone?.id, 'M001', 'absent-context: activeMilestone is M001'); + assert(state.phase !== 'blocked', 'absent-context: phase is not blocked'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 5: forward-dep ───────────────────────────────────────── + // M001 depends_on M002, but M002 is already complete → M001 can activate + console.log('\n=== forward-dep ==='); + { + const base = createFixtureBase(); + try { + // M001: depends on M002, but M002 is complete so M001 is unblocked + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** Depends on M002 which is already complete. + +## Slices + +- [ ] **S01: Ready** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M001', 'depends_on: [M002]'); + + // M002: complete (all slices [x] + SUMMARY) + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Already complete. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeMilestoneSummary(base, 'M002', '# M002 Summary\n\nSecond milestone is complete.'); + + const state = await deriveState(base); + + assertEq(state.activeMilestone?.id, 'M001', 'forward-dep: activeMilestone is M001'); + assertEq(state.registry[1]?.status, 'complete', 'forward-dep: M002 is complete'); + assert(state.phase !== 'blocked', 'forward-dep: phase is not blocked'); + } finally { + cleanup(base); + } + } + + // ─── Test Group 6: empty-deps-list ───────────────────────────────────── + // M002 has `depends_on: []` — empty list means no constraint, normal sequential behavior + console.log('\n=== empty-deps-list ==='); + { + const base = createFixtureBase(); + try { + // M001: incomplete, no context + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** First milestone still in progress. + +## Slices + +- [ ] **S01: Incomplete** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + // M002: empty deps list — no constraint from deps, but still sequential after M001 + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Empty deps list, no blocking constraint. + +## Slices + +- [ ] **S01: Waiting for M001** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeContext(base, 'M002', 'depends_on: []'); + + const state = await deriveState(base); + + assertEq(state.registry[0]?.status, 'active', 'empty-deps-list: M001 is active'); + assertEq(state.registry[1]?.status, 'pending', 'empty-deps-list: M002 is pending (M001 not done yet)'); + assert(state.phase !== 'blocked', 'empty-deps-list: phase is not blocked'); + } finally { + cleanup(base); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Results + // ═════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed ✓'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/derive-state.test.ts b/src/resources/extensions/gsd/tests/derive-state.test.ts new file mode 100644 index 000000000..4f485a718 --- /dev/null +++ b/src/resources/extensions/gsd/tests/derive-state.test.ts @@ -0,0 +1,637 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveState, isSliceComplete, isMilestoneComplete } from '../state.ts'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-state-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writePlan(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(join(dir, 'tasks'), { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), content); +} + +function writeContinue(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}-CONTINUE.md`), content); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +function writeRequirements(base: string, content: string): void { + writeFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Groups +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Test 1: empty milestones dir → pre-planning ─────────────────────── + console.log('\n=== empty milestones dir → pre-planning ==='); + { + const base = createFixtureBase(); + try { + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning'); + assertEq(state.activeMilestone, null, 'activeMilestone is null'); + assertEq(state.activeSlice, null, 'activeSlice is null'); + assertEq(state.activeTask, null, 'activeTask is null'); + assertEq(state.registry, [], 'registry is empty'); + assertEq(state.progress?.milestones?.done, 0, 'milestones done = 0'); + assertEq(state.progress?.milestones?.total, 0, 'milestones total = 0'); + } finally { + cleanup(base); + } + } + + // ─── Test 2: milestone dir exists but no roadmap → pre-planning ──────── + console.log('\n=== milestone dir exists but no roadmap → pre-planning ==='); + { + const base = createFixtureBase(); + try { + // Create M001 directory but no roadmap file + mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning'); + assert(state.activeMilestone !== null, 'activeMilestone is not null'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001'); + assertEq(state.activeSlice, null, 'activeSlice is null'); + assertEq(state.activeTask, null, 'activeTask is null'); + assertEq(state.registry.length, 1, 'registry has 1 entry'); + assertEq(state.registry[0]?.status, 'active', 'registry entry status is active'); + } finally { + cleanup(base); + } + } + + // ─── Test 3: roadmap with incomplete slice, no plan → planning ───────── + console.log('\n=== roadmap with incomplete slice, no plan → planning ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test planning phase. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: Slice is done. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'planning', 'phase is planning'); + assert(state.activeSlice !== null, 'activeSlice is not null'); + assertEq(state.activeSlice?.id, 'S01', 'activeSlice id is S01'); + assertEq(state.activeTask, null, 'activeTask is null'); + assertEq(state.progress?.slices?.done, 0, 'slices done = 0'); + assertEq(state.progress?.slices?.total, 1, 'slices total = 1'); + } finally { + cleanup(base); + } + } + + // ─── Test 4: roadmap + plan with incomplete tasks → executing ────────── + console.log('\n=== roadmap + plan with incomplete tasks → executing ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test executing phase. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: Slice is done. +`); + + writePlan(base, 'M001', 'S01', `# S01: Test Slice + +**Goal:** Test executing. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: First** \`est:10m\` + First task description. + +- [ ] **T02: Second** \`est:10m\` + Second task description. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'executing', 'phase is executing'); + assert(state.activeTask !== null, 'activeTask is not null'); + assertEq(state.activeTask?.id, 'T01', 'activeTask id is T01'); + assertEq(state.progress?.tasks?.done, 0, 'tasks done = 0'); + assertEq(state.progress?.tasks?.total, 2, 'tasks total = 2'); + } finally { + cleanup(base); + } + } + + // ─── Test 5: executing + continue file → resume message ───────────── + console.log('\n=== executing + continue file → resume message ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test interrupted resume. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: Slice is done. +`); + + writePlan(base, 'M001', 'S01', `# S01: Test Slice + +**Goal:** Test interrupted. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: First Task** \`est:10m\` + First task description. +`); + + writeContinue(base, 'M001', 'S01', `--- +milestone: M001 +slice: S01 +task: T01 +step: 2 +totalSteps: 5 +status: interrupted +savedAt: 2026-03-10T10:00:00Z +--- + +# Continue: T01 + +## Completed Work +Steps 1 done. + +## Remaining Work +Steps 2-5. + +## Next Action +Continue from step 2. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'executing', 'interrupted: phase is executing'); + assert(state.activeTask !== null, 'interrupted: activeTask is not null'); + assertEq(state.activeTask?.id, 'T01', 'interrupted: activeTask id is T01'); + assert( + state.nextAction.includes('Resume') || state.nextAction.includes('resume') || state.nextAction.includes('continue.md'), + 'interrupted: nextAction mentions Resume/resume/continue.md' + ); + } finally { + cleanup(base); + } + } + + // ─── Test 6: all tasks done, slice not [x] → summarizing ────────────── + console.log('\n=== all tasks done, slice not [x] → summarizing ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test summarizing phase. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: Slice is done. +`); + + writePlan(base, 'M001', 'S01', `# S01: Test Slice + +**Goal:** Test summarizing. +**Demo:** Tests pass. + +## Tasks + +- [x] **T01: First Done** \`est:10m\` + Already completed. + +- [x] **T02: Second Done** \`est:10m\` + Also completed. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'summarizing', 'summarizing: phase is summarizing'); + assert(state.activeSlice !== null, 'summarizing: activeSlice is not null'); + assertEq(state.activeSlice?.id, 'S01', 'summarizing: activeSlice id is S01'); + assertEq(state.activeTask, null, 'summarizing: activeTask is null'); + assert( + state.nextAction.toLowerCase().includes('summary') || state.nextAction.toLowerCase().includes('complete'), + 'summarizing: nextAction mentions summary or complete' + ); + assertEq(state.progress?.tasks?.done, 2, 'summarizing: tasks done = 2'); + assertEq(state.progress?.tasks?.total, 2, 'summarizing: tasks total = 2'); + } finally { + cleanup(base); + } + } + + // ─── Test 7: all milestones complete → complete ──────────────────────── + console.log('\n=== all milestones complete → complete ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test complete phase. + +## Slices + +- [x] **S01: Done Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nMilestone complete.`); + + const state = await deriveState(base); + + assertEq(state.phase, 'complete', 'complete: phase is complete'); + assertEq(state.activeSlice, null, 'complete: activeSlice is null'); + assertEq(state.activeTask, null, 'complete: activeTask is null'); + assert( + state.nextAction.toLowerCase().includes('complete'), + 'complete: nextAction mentions complete' + ); + assertEq(state.registry.length, 1, 'complete: registry has 1 entry'); + assertEq(state.registry[0]?.status, 'complete', 'complete: registry[0] status is complete'); + } finally { + cleanup(base); + } + } + + // ─── Test 8: blocked dependencies ────────────────────────────────────── + console.log('\n=== blocked dependencies ==='); + { + // Case A: S01 active (deps satisfied), S02 blocked on S01 + const base1 = createFixtureBase(); + try { + writeRoadmap(base1, 'M001', `# M001: Test Milestone + +**Vision:** Test blocked deps. + +## Slices + +- [ ] **S01: First** \`risk:low\` \`depends:[]\` + > After this: S01 done. + +- [ ] **S02: Second** \`risk:low\` \`depends:[S01]\` + > After this: S02 done. +`); + + // S01 has a plan with incomplete task — it's the active slice + writePlan(base1, 'M001', 'S01', `# S01: First + +**Goal:** First slice. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: Incomplete** \`est:10m\` + Still working. +`); + + const state1 = await deriveState(base1); + + assertEq(state1.phase, 'executing', 'blocked-A: phase is executing (S01 active)'); + assertEq(state1.activeSlice?.id, 'S01', 'blocked-A: activeSlice is S01'); + } finally { + cleanup(base1); + } + + // Case B: S01 depends on nonexistent S99 → truly blocked + const base2 = createFixtureBase(); + try { + writeRoadmap(base2, 'M001', `# M001: Test Milestone + +**Vision:** Test truly blocked. + +## Slices + +- [ ] **S01: Blocked** \`risk:low\` \`depends:[S99]\` + > After this: Done. +`); + + const state2 = await deriveState(base2); + + assertEq(state2.phase, 'blocked', 'blocked-B: phase is blocked'); + assertEq(state2.activeSlice, null, 'blocked-B: activeSlice is null'); + assert(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty'); + } finally { + cleanup(base2); + } + } + + // ─── Test 9: multi-milestone registry ────────────────────────────────── + console.log('\n=== multi-milestone registry ==='); + { + const base = createFixtureBase(); + try { + // M001: complete (all slices done) + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** Already done. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nFirst milestone complete.`); + + // M002: active (has incomplete slices) + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** Currently active. + +## Slices + +- [ ] **S01: In Progress** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + // M003: just a dir (no roadmap → pending since M002 is already active) + mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true }); + + const state = await deriveState(base); + + assertEq(state.registry.length, 3, 'multi-ms: registry has 3 entries'); + assertEq(state.registry[0]?.id, 'M001', 'multi-ms: registry[0] is M001'); + assertEq(state.registry[0]?.status, 'complete', 'multi-ms: M001 is complete'); + assertEq(state.registry[1]?.id, 'M002', 'multi-ms: registry[1] is M002'); + assertEq(state.registry[1]?.status, 'active', 'multi-ms: M002 is active'); + assertEq(state.registry[2]?.id, 'M003', 'multi-ms: registry[2] is M003'); + assertEq(state.registry[2]?.status, 'pending', 'multi-ms: M003 is pending'); + assertEq(state.activeMilestone?.id, 'M002', 'multi-ms: activeMilestone is M002'); + assertEq(state.progress?.milestones?.done, 1, 'multi-ms: milestones done = 1'); + assertEq(state.progress?.milestones?.total, 3, 'multi-ms: milestones total = 3'); + } finally { + cleanup(base); + } + } + + // ─── Test 10: requirements integration ───────────────────────────────── + console.log('\n=== requirements integration ==='); + { + const base = createFixtureBase(); + try { + writeRequirements(base, `# Requirements + +## Active + +### R001 — First Active Requirement +- Status: active +- Description: Something active. + +### R002 — Second Active Requirement +- Status: active +- Description: Another active one. + +## Validated + +### R003 — Validated Requirement +- Status: validated +- Description: Already validated. + +## Deferred + +### R004 — Deferred Requirement +- Status: deferred +- Description: Pushed back. + +### R005 — Another Deferred +- Status: deferred +- Description: Also deferred. + +## Out of Scope + +### R006 — Out of Scope Requirement +- Status: out-of-scope +- Description: Not doing this. +`); + + // Need at least an empty milestones dir for deriveState + const state = await deriveState(base); + + assert(state.requirements !== undefined, 'requirements: requirements object exists'); + assertEq(state.requirements?.active, 2, 'requirements: active = 2'); + assertEq(state.requirements?.validated, 1, 'requirements: validated = 1'); + assertEq(state.requirements?.deferred, 2, 'requirements: deferred = 2'); + assertEq(state.requirements?.outOfScope, 1, 'requirements: outOfScope = 1'); + assertEq(state.requirements?.total, 6, 'requirements: total = 6 (sum of all)'); + } finally { + cleanup(base); + } + } + + // ─── Test 11: all slices [x], no summary → completing-milestone ──────── + console.log('\n=== all slices [x], no summary → completing-milestone ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test completing-milestone phase. + +## Slices + +- [x] **S01: First Done** \`risk:low\` \`depends:[]\` + > After this: S01 complete. + +- [x] **S02: Second Done** \`risk:low\` \`depends:[S01]\` + > After this: S02 complete. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'completing-milestone', 'completing-ms: phase is completing-milestone'); + assert(state.activeMilestone !== null, 'completing-ms: activeMilestone is not null'); + assertEq(state.activeMilestone?.id, 'M001', 'completing-ms: activeMilestone id is M001'); + assertEq(state.activeSlice, null, 'completing-ms: activeSlice is null'); + assertEq(state.activeTask, null, 'completing-ms: activeTask is null'); + assertEq(state.registry.length, 1, 'completing-ms: registry has 1 entry'); + assertEq(state.registry[0]?.status, 'active', 'completing-ms: registry[0] status is active (not complete)'); + assertEq(state.progress?.slices?.done, 2, 'completing-ms: slices done = 2'); + assertEq(state.progress?.slices?.total, 2, 'completing-ms: slices total = 2'); + assert( + state.nextAction.toLowerCase().includes('summary') || state.nextAction.toLowerCase().includes('complete'), + 'completing-ms: nextAction mentions summary or complete' + ); + } finally { + cleanup(base); + } + } + + // ─── Test 12: all slices [x], summary exists → complete ─────────────── + console.log('\n=== all slices [x], summary exists → complete ==='); + { + const base = createFixtureBase(); + try { + writeRoadmap(base, 'M001', `# M001: Test Milestone + +**Vision:** Test that summary presence means complete. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nMilestone is complete.`); + + const state = await deriveState(base); + + assertEq(state.phase, 'complete', 'summary-exists: phase is complete'); + assertEq(state.registry.length, 1, 'summary-exists: registry has 1 entry'); + assertEq(state.registry[0]?.status, 'complete', 'summary-exists: registry[0] status is complete'); + assertEq(state.activeSlice, null, 'summary-exists: activeSlice is null'); + assertEq(state.activeTask, null, 'summary-exists: activeTask is null'); + } finally { + cleanup(base); + } + } + + // ─── Test 13: multi-milestone completing-milestone ───────────────────── + console.log('\n=== multi-milestone completing-milestone ==='); + { + const base = createFixtureBase(); + try { + // M001: all slices done + summary exists → complete + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** Already complete with summary. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeMilestoneSummary(base, 'M001', `# M001 Summary\n\nFirst milestone complete.`); + + // M002: all slices done, no summary → completing-milestone + writeRoadmap(base, 'M002', `# M002: Second Milestone + +**Vision:** All slices done but no summary. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. + +- [x] **S02: Also Done** \`risk:low\` \`depends:[S01]\` + > After this: Done. +`); + + // M003: has incomplete slices → pending (M002 is active) + writeRoadmap(base, 'M003', `# M003: Third Milestone + +**Vision:** Not yet started. + +## Slices + +- [ ] **S01: Not Started** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'completing-milestone', 'multi-completing: phase is completing-milestone'); + assertEq(state.activeMilestone?.id, 'M002', 'multi-completing: activeMilestone is M002'); + assertEq(state.activeSlice, null, 'multi-completing: activeSlice is null'); + assertEq(state.activeTask, null, 'multi-completing: activeTask is null'); + assertEq(state.registry.length, 3, 'multi-completing: registry has 3 entries'); + assertEq(state.registry[0]?.id, 'M001', 'multi-completing: registry[0] is M001'); + assertEq(state.registry[0]?.status, 'complete', 'multi-completing: M001 is complete'); + assertEq(state.registry[1]?.id, 'M002', 'multi-completing: registry[1] is M002'); + assertEq(state.registry[1]?.status, 'active', 'multi-completing: M002 is active (completing-milestone)'); + assertEq(state.registry[2]?.id, 'M003', 'multi-completing: registry[2] is M003'); + assertEq(state.registry[2]?.status, 'pending', 'multi-completing: M003 is pending'); + assertEq(state.progress?.milestones?.done, 1, 'multi-completing: milestones done = 1'); + assertEq(state.progress?.milestones?.total, 3, 'multi-completing: milestones total = 3'); + assertEq(state.progress?.slices?.done, 2, 'multi-completing: slices done = 2'); + assertEq(state.progress?.slices?.total, 2, 'multi-completing: slices total = 2'); + } finally { + cleanup(base); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Results + // ═════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed ✓'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/doctor.test.ts b/src/resources/extensions/gsd/tests/doctor.test.ts new file mode 100644 index 000000000..037c2a1d3 --- /dev/null +++ b/src/resources/extensions/gsd/tests/doctor.test.ts @@ -0,0 +1,505 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope } from "../doctor.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-test-")); +const gsd = join(tmpBase, ".gsd"); +const mDir = join(gsd, "milestones", "M001"); +const sDir = join(mDir, "slices", "S01"); +const tDir = join(sDir, "tasks"); +mkdirSync(tDir, { recursive: true }); + +writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Test Milestone + +## Slices +- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\` + > After this: demo works +`); + +writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice + +**Goal:** Demo +**Demo:** Demo + +## Must-Haves +- done + +## Tasks +- [x] **T01: Implement thing** \`est:10m\` + Task is complete. +`); + +writeFileSync(join(tDir, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +provides: [] +requires: [] +affects: [] +key_files: [] +key_decisions: [] +patterns_established: [] +observability_surfaces: [] +drill_down_paths: [] +duration: 10m +verification_result: passed +completed_at: 2026-03-09T00:00:00Z +--- + +# T01: Implement thing + +**Done** + +## What Happened +Implemented. + +## Diagnostics +- log +`); + +async function main(): Promise { + console.log("\n=== doctor diagnose ==="); + { + const report = await runGSDDoctor(tmpBase, { fix: false }); + assert(!report.ok, "report is not ok when completion artifacts are missing"); + assert(report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary"), "detects missing slice summary"); + assert(report.issues.some(issue => issue.code === "all_tasks_done_missing_slice_uat"), "detects missing slice UAT"); + } + + console.log("\n=== doctor formatting ==="); + { + const report = await runGSDDoctor(tmpBase, { fix: false }); + const summary = summarizeDoctorIssues(report.issues); + assertEq(summary.errors, 2, "two blocking errors in summary"); + const scoped = filterDoctorIssues(report.issues, { scope: "M001/S01", includeWarnings: true }); + assert(scoped.length >= 2, "scope filter keeps slice issues"); + const text = formatDoctorReport(report, { scope: "M001/S01", includeWarnings: true, maxIssues: 5 }); + assert(text.includes("Scope: M001/S01"), "formatted report shows scope"); + assert(text.includes("Top issue types:"), "formatted report shows grouped issue types"); + } + + console.log("\n=== doctor default scope ==="); + { + const scope = await selectDoctorScope(tmpBase); + assertEq(scope, "M001/S01", "default doctor scope targets the active slice"); + } + + console.log("\n=== doctor fix ==="); + { + const report = await runGSDDoctor(tmpBase, { fix: true }); + if (report.fixesApplied.length < 3) console.error(report); + assert(report.fixesApplied.length >= 3, "applies multiple fixes"); + assert(existsSync(join(sDir, "S01-SUMMARY.md")), "creates placeholder slice summary"); + assert(existsSync(join(sDir, "S01-UAT.md")), "creates placeholder UAT"); + + const plan = readFileSync(join(sDir, "S01-PLAN.md"), "utf-8"); + assert(plan.includes("- [x] **T01:"), "marks task checkbox done"); + + const roadmap = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8"); + assert(roadmap.includes("- [x] **S01:"), "marks slice checkbox done"); + + const state = readFileSync(join(gsd, "STATE.md"), "utf-8"); + assert(state.includes("# GSD State"), "writes state file"); + } + + rmSync(tmpBase, { recursive: true, force: true }); + + // ─── Milestone summary detection: missing summary ────────────────────── + console.log("\n=== doctor detects missing milestone summary ==="); + { + const msBase = mkdtempSync(join(tmpdir(), "gsd-doctor-ms-test-")); + const msGsd = join(msBase, ".gsd"); + const msMDir = join(msGsd, "milestones", "M001"); + const msSDir = join(msMDir, "slices", "S01"); + const msTDir = join(msSDir, "tasks"); + mkdirSync(msTDir, { recursive: true }); + + // Roadmap with ALL slices [x] — milestone is complete by slice status + writeFileSync(join(msMDir, "M001-ROADMAP.md"), `# M001: Test Milestone + +## Slices +- [x] **S01: Done Slice** \`risk:low\` \`depends:[]\` + > After this: done +`); + + // Slice has plan with all tasks done + writeFileSync(join(msSDir, "S01-PLAN.md"), `# S01: Done Slice + +**Goal:** Done +**Demo:** Done + +## Tasks +- [x] **T01: Done Task** \`est:10m\` + Done. +`); + + // Task summary exists + writeFileSync(join(msTDir, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +--- +# T01: Done +**Done** +## What Happened +Done. +`); + + // Slice summary exists (so slice-level checks pass) + writeFileSync(join(msSDir, "S01-SUMMARY.md"), `--- +id: S01 +parent: M001 +--- +# S01: Done +`); + + // Slice UAT exists (so slice-level checks pass) + writeFileSync(join(msSDir, "S01-UAT.md"), `# S01 UAT\nDone.\n`); + + // NO milestone summary — this is the condition we're detecting + + const report = await runGSDDoctor(msBase, { fix: false }); + assert( + report.issues.some(issue => issue.code === "all_slices_done_missing_milestone_summary"), + "detects missing milestone summary when all slices are done" + ); + const msIssue = report.issues.find(issue => issue.code === "all_slices_done_missing_milestone_summary"); + assertEq(msIssue?.scope, "milestone", "milestone summary issue has scope 'milestone'"); + assertEq(msIssue?.severity, "warning", "milestone summary issue has severity 'warning'"); + assertEq(msIssue?.unitId, "M001", "milestone summary issue unitId is 'M001'"); + assert(msIssue?.message?.includes("SUMMARY") ?? false, "milestone summary issue message mentions SUMMARY"); + + rmSync(msBase, { recursive: true, force: true }); + } + + // ─── Milestone summary detection: summary present (no false positive) ── + console.log("\n=== doctor does NOT flag milestone with summary ==="); + { + const msBase = mkdtempSync(join(tmpdir(), "gsd-doctor-ms-ok-test-")); + const msGsd = join(msBase, ".gsd"); + const msMDir = join(msGsd, "milestones", "M001"); + const msSDir = join(msMDir, "slices", "S01"); + const msTDir = join(msSDir, "tasks"); + mkdirSync(msTDir, { recursive: true }); + + // Roadmap with ALL slices [x] + writeFileSync(join(msMDir, "M001-ROADMAP.md"), `# M001: Test Milestone + +## Slices +- [x] **S01: Done Slice** \`risk:low\` \`depends:[]\` + > After this: done +`); + + writeFileSync(join(msSDir, "S01-PLAN.md"), `# S01: Done Slice + +**Goal:** Done +**Demo:** Done + +## Tasks +- [x] **T01: Done Task** \`est:10m\` + Done. +`); + + writeFileSync(join(msTDir, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +--- +# T01: Done +**Done** +## What Happened +Done. +`); + + writeFileSync(join(msSDir, "S01-SUMMARY.md"), `--- +id: S01 +parent: M001 +--- +# S01: Done +`); + + writeFileSync(join(msSDir, "S01-UAT.md"), `# S01 UAT\nDone.\n`); + + // Milestone summary EXISTS + writeFileSync(join(msMDir, "M001-SUMMARY.md"), `# M001 Summary\n\nMilestone complete.`); + + const report = await runGSDDoctor(msBase, { fix: false }); + assert( + !report.issues.some(issue => issue.code === "all_slices_done_missing_milestone_summary"), + "does NOT report missing milestone summary when summary exists" + ); + + rmSync(msBase, { recursive: true, force: true }); + } + + // ─── blocker_discovered_no_replan detection ──────────────────────────── + console.log("\n=== doctor detects blocker_discovered_no_replan ==="); + { + const bBase = mkdtempSync(join(tmpdir(), "gsd-doctor-blocker-test-")); + const bGsd = join(bBase, ".gsd"); + const bMDir = join(bGsd, "milestones", "M001"); + const bSDir = join(bMDir, "slices", "S01"); + const bTDir = join(bSDir, "tasks"); + mkdirSync(bTDir, { recursive: true }); + + writeFileSync(join(bMDir, "M001-ROADMAP.md"), `# M001: Test Milestone + +## Slices +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: stuff works +`); + + writeFileSync(join(bSDir, "S01-PLAN.md"), `# S01: Test Slice + +**Goal:** Test +**Demo:** Test + +## Tasks +- [x] **T01: First task** \`est:10m\` + First task. + +- [ ] **T02: Second task** \`est:10m\` + Second task. +`); + + // Task summary with blocker_discovered: true + writeFileSync(join(bTDir, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +provides: [] +key_files: [] +key_decisions: [] +patterns_established: [] +observability_surfaces: [] +duration: 10m +verification_result: passed +completed_at: 2026-03-10T00:00:00Z +blocker_discovered: true +--- + +# T01: First task + +**Found a blocker.** + +## What Happened + +Discovered an issue. +`); + + // No REPLAN.md — should trigger the issue + const report = await runGSDDoctor(bBase, { fix: false }); + const blockerIssues = report.issues.filter(i => i.code === "blocker_discovered_no_replan"); + assert(blockerIssues.length > 0, "detects blocker_discovered_no_replan"); + assertEq(blockerIssues[0]?.severity, "warning", "blocker issue has warning severity"); + assertEq(blockerIssues[0]?.scope, "slice", "blocker issue has slice scope"); + assert(blockerIssues[0]?.message?.includes("T01") ?? false, "blocker issue message mentions T01"); + assert(blockerIssues[0]?.message?.includes("S01") ?? false, "blocker issue message mentions S01"); + + rmSync(bBase, { recursive: true, force: true }); + } + + // ─── blocker_discovered with REPLAN.md (no false positive) ───────────── + console.log("\n=== doctor does NOT flag blocker when REPLAN.md exists ==="); + { + const bBase = mkdtempSync(join(tmpdir(), "gsd-doctor-blocker-ok-test-")); + const bGsd = join(bBase, ".gsd"); + const bMDir = join(bGsd, "milestones", "M001"); + const bSDir = join(bMDir, "slices", "S01"); + const bTDir = join(bSDir, "tasks"); + mkdirSync(bTDir, { recursive: true }); + + writeFileSync(join(bMDir, "M001-ROADMAP.md"), `# M001: Test Milestone + +## Slices +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: stuff works +`); + + writeFileSync(join(bSDir, "S01-PLAN.md"), `# S01: Test Slice + +**Goal:** Test +**Demo:** Test + +## Tasks +- [x] **T01: First task** \`est:10m\` + First task. + +- [ ] **T02: Second task** \`est:10m\` + Second task. +`); + + writeFileSync(join(bTDir, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +blocker_discovered: true +completed_at: 2026-03-10T00:00:00Z +--- + +# T01: First task + +**Found a blocker.** + +## What Happened + +Discovered an issue. +`); + + // REPLAN.md exists — should NOT trigger + writeFileSync(join(bSDir, "S01-REPLAN.md"), `# Replan\n\nAlready replanned.`); + + const report = await runGSDDoctor(bBase, { fix: false }); + const blockerIssues = report.issues.filter(i => i.code === "blocker_discovered_no_replan"); + assertEq(blockerIssues.length, 0, "no blocker_discovered_no_replan when REPLAN.md exists"); + + rmSync(bBase, { recursive: true, force: true }); + } + + // ─── Must-have verification: all addressed → no issue ───────────────── + console.log("\n=== doctor: done task with must-haves all addressed → no issue ==="); + { + const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-ok-")); + const mhGsd = join(mhBase, ".gsd"); + const mhMDir = join(mhGsd, "milestones", "M001"); + const mhSDir = join(mhMDir, "slices", "S01"); + const mhTDir = join(mhSDir, "tasks"); + mkdirSync(mhTDir, { recursive: true }); + + writeFileSync(join(mhMDir, "M001-ROADMAP.md"), `# M001: Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`); + writeFileSync(join(mhSDir, "S01-PLAN.md"), `# S01: Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [x] **T01: Implement** \`est:10m\`\n Done.\n`); + + // Task plan with must-haves + writeFileSync(join(mhTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Must-Haves\n\n- [ ] \`parseWidgets\` function exported\n- [ ] Unit tests pass with zero failures\n`); + + // Summary mentioning both must-haves + writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nAdded parseWidgets function. Unit tests pass with zero failures.\n`); + + const report = await runGSDDoctor(mhBase, { fix: false }); + assert( + !report.issues.some(i => i.code === "task_done_must_haves_not_verified"), + "no must-have issue when all must-haves are addressed" + ); + + rmSync(mhBase, { recursive: true, force: true }); + } + + // ─── Must-have verification: not addressed → warning fired ─────────── + console.log("\n=== doctor: done task with must-haves NOT addressed → warning ==="); + { + const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-fail-")); + const mhGsd = join(mhBase, ".gsd"); + const mhMDir = join(mhGsd, "milestones", "M001"); + const mhSDir = join(mhMDir, "slices", "S01"); + const mhTDir = join(mhSDir, "tasks"); + mkdirSync(mhTDir, { recursive: true }); + + writeFileSync(join(mhMDir, "M001-ROADMAP.md"), `# M001: Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`); + writeFileSync(join(mhSDir, "S01-PLAN.md"), `# S01: Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [x] **T01: Implement** \`est:10m\`\n Done.\n`); + + // Task plan with 3 must-haves + writeFileSync(join(mhTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Must-Haves\n\n- [ ] \`parseWidgets\` function exported\n- [ ] \`countWidgets\` utility added\n- [ ] Full regression suite passes\n`); + + // Summary mentions only parseWidgets — the other two are missing + writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nAdded parseWidgets function.\n`); + + const report = await runGSDDoctor(mhBase, { fix: false }); + const mhIssue = report.issues.find(i => i.code === "task_done_must_haves_not_verified"); + assert(!!mhIssue, "must-have issue is fired when summary doesn't address all must-haves"); + assertEq(mhIssue?.severity, "warning", "must-have issue is warning severity"); + assertEq(mhIssue?.scope, "task", "must-have issue scope is task"); + assert(mhIssue?.message?.includes("3 must-haves") ?? false, "message mentions total must-have count"); + assert(mhIssue?.message?.includes("only 1") ?? false, "message mentions addressed count"); + assertEq(mhIssue?.fixable, false, "must-have issue is not fixable"); + + rmSync(mhBase, { recursive: true, force: true }); + } + + // ─── Must-have verification: no task plan → no issue ───────────────── + console.log("\n=== doctor: done task with no task plan file → no issue ==="); + { + const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-noplan-")); + const mhGsd = join(mhBase, ".gsd"); + const mhMDir = join(mhGsd, "milestones", "M001"); + const mhSDir = join(mhMDir, "slices", "S01"); + const mhTDir = join(mhSDir, "tasks"); + mkdirSync(mhTDir, { recursive: true }); + + writeFileSync(join(mhMDir, "M001-ROADMAP.md"), `# M001: Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`); + writeFileSync(join(mhSDir, "S01-PLAN.md"), `# S01: Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [x] **T01: Implement** \`est:10m\`\n Done.\n`); + + // NO task plan file — just a summary + writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nDone.\n`); + + const report = await runGSDDoctor(mhBase, { fix: false }); + assert( + !report.issues.some(i => i.code === "task_done_must_haves_not_verified"), + "no must-have issue when task plan file doesn't exist" + ); + + rmSync(mhBase, { recursive: true, force: true }); + } + + // ─── Must-have verification: plan exists but no Must-Haves section → no issue + console.log("\n=== doctor: done task with plan but no Must-Haves section → no issue ==="); + { + const mhBase = mkdtempSync(join(tmpdir(), "gsd-doctor-mh-nosect-")); + const mhGsd = join(mhBase, ".gsd"); + const mhMDir = join(mhGsd, "milestones", "M001"); + const mhSDir = join(mhMDir, "slices", "S01"); + const mhTDir = join(mhSDir, "tasks"); + mkdirSync(mhTDir, { recursive: true }); + + writeFileSync(join(mhMDir, "M001-ROADMAP.md"), `# M001: Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`); + writeFileSync(join(mhSDir, "S01-PLAN.md"), `# S01: Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [x] **T01: Implement** \`est:10m\`\n Done.\n`); + + // Task plan with NO Must-Haves section + writeFileSync(join(mhTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n\n## Verification\n\n- Run tests.\n`); + + writeFileSync(join(mhTDir, "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01: Implement\n\n## What Happened\nDone.\n`); + + const report = await runGSDDoctor(mhBase, { fix: false }); + assert( + !report.issues.some(i => i.code === "task_done_must_haves_not_verified"), + "no must-have issue when task plan has no Must-Haves section" + ); + + rmSync(mhBase, { recursive: true, force: true }); + } + + console.log(`\n${"=".repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log("All tests passed ✓"); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/metrics-io.test.ts b/src/resources/extensions/gsd/tests/metrics-io.test.ts new file mode 100644 index 000000000..6e16fa2d1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/metrics-io.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for GSD metrics disk I/O — init, snapshot, load/save cycle. + * Uses a temp directory to avoid touching real .gsd/ state. + */ + +import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + initMetrics, + resetMetrics, + getLedger, + snapshotUnitMetrics, + type MetricsLedger, +} from "../metrics.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-metrics-test-")); +mkdirSync(join(tmpBase, ".gsd"), { recursive: true }); + +// Mock ExtensionContext with session entries +function mockCtx(messages: any[] = []): any { + const entries = messages.map((msg, i) => ({ + type: "message", + id: `entry-${i}`, + parentId: i > 0 ? `entry-${i - 1}` : null, + timestamp: new Date().toISOString(), + message: msg, + })); + return { + sessionManager: { + getEntries: () => entries, + }, + model: { id: "claude-sonnet-4-20250514" }, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +console.log("\n=== initMetrics / getLedger ==="); + +{ + resetMetrics(); + assert(getLedger() === null, "ledger null before init"); + + initMetrics(tmpBase); + const ledger = getLedger(); + assert(ledger !== null, "ledger not null after init"); + assertEq(ledger!.version, 1, "version is 1"); + assertEq(ledger!.units.length, 0, "no units initially"); +} + +console.log("\n=== snapshotUnitMetrics ==="); + +{ + resetMetrics(); + initMetrics(tmpBase); + + // Simulate a session with assistant messages containing usage data + const ctx = mockCtx([ + { role: "user", content: "Do the thing" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll do the thing" }, + { type: "tool_call", id: "tc1", name: "bash", input: {} }, + ], + usage: { + input: 5000, + output: 2000, + cacheRead: 3000, + cacheWrite: 500, + totalTokens: 10500, + cost: { input: 0.015, output: 0.03, cacheRead: 0.003, cacheWrite: 0.002, total: 0.05 }, + }, + }, + { role: "toolResult", toolCallId: "tc1", content: [{ type: "text", text: "ok" }] }, + { + role: "assistant", + content: [{ type: "text", text: "Done!" }], + usage: { + input: 8000, + output: 1000, + cacheRead: 6000, + cacheWrite: 200, + totalTokens: 15200, + cost: { input: 0.024, output: 0.015, cacheRead: 0.006, cacheWrite: 0.001, total: 0.046 }, + }, + }, + ]); + + const unit = snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", Date.now() - 5000, "claude-sonnet-4-20250514"); + + assert(unit !== null, "unit returned"); + assertEq(unit!.type, "execute-task", "type"); + assertEq(unit!.id, "M001/S01/T01", "id"); + assertEq(unit!.tokens.input, 13000, "input tokens (5000+8000)"); + assertEq(unit!.tokens.output, 3000, "output tokens (2000+1000)"); + assertEq(unit!.tokens.cacheRead, 9000, "cacheRead (3000+6000)"); + assertEq(unit!.tokens.total, 25700, "total tokens (10500+15200)"); + assert(Math.abs(unit!.cost - 0.096) < 0.001, `cost ~0.096 (got ${unit!.cost})`); + assertEq(unit!.toolCalls, 1, "1 tool call"); + assertEq(unit!.assistantMessages, 2, "2 assistant messages"); + assertEq(unit!.userMessages, 1, "1 user message"); + + // Verify ledger persisted + const ledger = getLedger()!; + assertEq(ledger.units.length, 1, "1 unit in ledger"); +} + +console.log("\n=== Persistence across init/reset cycles ==="); + +{ + // Reset and re-init — should load from disk + resetMetrics(); + initMetrics(tmpBase); + + const ledger = getLedger()!; + assertEq(ledger.units.length, 1, "unit survived reset+init"); + assertEq(ledger.units[0].id, "M001/S01/T01", "correct unit ID"); + + // Add another unit + const ctx = mockCtx([ + { + role: "assistant", + content: [{ type: "text", text: "Research complete" }], + usage: { + input: 3000, output: 1500, cacheRead: 1000, cacheWrite: 300, totalTokens: 5800, + cost: { input: 0.009, output: 0.023, cacheRead: 0.001, cacheWrite: 0.001, total: 0.034 }, + }, + }, + ]); + + snapshotUnitMetrics(ctx, "research-slice", "M001/S02", Date.now() - 3000, "claude-sonnet-4-20250514"); + + // Verify both units persisted + resetMetrics(); + initMetrics(tmpBase); + const final = getLedger()!; + assertEq(final.units.length, 2, "2 units after second snapshot"); +} + +console.log("\n=== File content verification ==="); + +{ + const raw = readFileSync(join(tmpBase, ".gsd", "metrics.json"), "utf-8"); + const parsed: MetricsLedger = JSON.parse(raw); + assertEq(parsed.version, 1, "file version is 1"); + assertEq(parsed.units.length, 2, "file has 2 units"); + assert(parsed.projectStartedAt > 0, "projectStartedAt is set"); +} + +console.log("\n=== Empty session handling ==="); + +{ + resetMetrics(); + initMetrics(tmpBase); + + // Empty session — no messages + const ctx = mockCtx([]); + const unit = snapshotUnitMetrics(ctx, "plan-slice", "M001/S01", Date.now(), "test-model"); + assert(unit === null, "returns null for empty session"); + + // Ledger shouldn't have grown + assertEq(getLedger()!.units.length, 2, "still 2 units (empty session not added)"); +} + +// ─── Cleanup ────────────────────────────────────────────────────────────────── + +resetMetrics(); +rmSync(tmpBase, { recursive: true, force: true }); + +console.log(`\n${"=".repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} else { + console.log("All tests passed ✓"); +} diff --git a/src/resources/extensions/gsd/tests/metrics.test.ts b/src/resources/extensions/gsd/tests/metrics.test.ts new file mode 100644 index 000000000..8408901a8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/metrics.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for GSD metrics aggregation logic. + * Tests the pure functions — no file I/O, no extension context. + */ + +import { + type UnitMetrics, + type TokenCounts, + classifyUnitPhase, + aggregateByPhase, + aggregateBySlice, + aggregateByModel, + getProjectTotals, + formatCost, + formatTokenCount, +} from "../metrics.js"; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeUnit(overrides: Partial = {}): UnitMetrics { + return { + type: "execute-task", + id: "M001/S01/T01", + model: "claude-sonnet-4-20250514", + startedAt: 1000, + finishedAt: 2000, + tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 }, + cost: 0.05, + toolCalls: 3, + assistantMessages: 2, + userMessages: 1, + ...overrides, + }; +} + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (actual === expected) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertClose(actual: number, expected: number, tolerance: number, message: string): void { + if (Math.abs(actual - expected) <= tolerance) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ~${expected}, got ${actual}`); + } +} + +// ─── Phase classification ───────────────────────────────────────────────────── + +console.log("\n=== classifyUnitPhase ==="); + +assertEq(classifyUnitPhase("research-milestone"), "research", "research-milestone → research"); +assertEq(classifyUnitPhase("research-slice"), "research", "research-slice → research"); +assertEq(classifyUnitPhase("plan-milestone"), "planning", "plan-milestone → planning"); +assertEq(classifyUnitPhase("plan-slice"), "planning", "plan-slice → planning"); +assertEq(classifyUnitPhase("execute-task"), "execution", "execute-task → execution"); +assertEq(classifyUnitPhase("complete-slice"), "completion", "complete-slice → completion"); +assertEq(classifyUnitPhase("reassess-roadmap"), "reassessment", "reassess-roadmap → reassessment"); +assertEq(classifyUnitPhase("unknown-thing"), "execution", "unknown → execution (fallback)"); + +// ─── getProjectTotals ───────────────────────────────────────────────────────── + +console.log("\n=== getProjectTotals ==="); + +{ + const units = [ + makeUnit({ tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 }, cost: 0.05, toolCalls: 3, startedAt: 1000, finishedAt: 2000 }), + makeUnit({ tokens: { input: 2000, output: 1000, cacheRead: 400, cacheWrite: 200, total: 3600 }, cost: 0.10, toolCalls: 5, startedAt: 2000, finishedAt: 4000 }), + ]; + const totals = getProjectTotals(units); + + assertEq(totals.units, 2, "total units"); + assertEq(totals.tokens.input, 3000, "total input tokens"); + assertEq(totals.tokens.output, 1500, "total output tokens"); + assertEq(totals.tokens.cacheRead, 600, "total cacheRead"); + assertEq(totals.tokens.cacheWrite, 300, "total cacheWrite"); + assertEq(totals.tokens.total, 5400, "total tokens"); + assertClose(totals.cost, 0.15, 0.001, "total cost"); + assertEq(totals.toolCalls, 8, "total tool calls"); + assertEq(totals.duration, 3000, "total duration"); +} + +{ + const totals = getProjectTotals([]); + assertEq(totals.units, 0, "empty: zero units"); + assertEq(totals.cost, 0, "empty: zero cost"); + assertEq(totals.tokens.total, 0, "empty: zero tokens"); +} + +// ─── aggregateByPhase ───────────────────────────────────────────────────────── + +console.log("\n=== aggregateByPhase ==="); + +{ + const units = [ + makeUnit({ type: "research-milestone", cost: 0.02 }), + makeUnit({ type: "research-slice", cost: 0.03 }), + makeUnit({ type: "plan-milestone", cost: 0.01 }), + makeUnit({ type: "plan-slice", cost: 0.02 }), + makeUnit({ type: "execute-task", cost: 0.10 }), + makeUnit({ type: "execute-task", cost: 0.08 }), + makeUnit({ type: "complete-slice", cost: 0.01 }), + makeUnit({ type: "reassess-roadmap", cost: 0.005 }), + ]; + const phases = aggregateByPhase(units); + + assertEq(phases.length, 5, "5 phases"); + assertEq(phases[0].phase, "research", "first phase is research"); + assertEq(phases[0].units, 2, "2 research units"); + assertClose(phases[0].cost, 0.05, 0.001, "research cost"); + + assertEq(phases[1].phase, "planning", "second phase is planning"); + assertEq(phases[1].units, 2, "2 planning units"); + + assertEq(phases[2].phase, "execution", "third phase is execution"); + assertEq(phases[2].units, 2, "2 execution units"); + assertClose(phases[2].cost, 0.18, 0.001, "execution cost"); + + assertEq(phases[3].phase, "completion", "fourth phase is completion"); + assertEq(phases[4].phase, "reassessment", "fifth phase is reassessment"); +} + +// ─── aggregateBySlice ───────────────────────────────────────────────────────── + +console.log("\n=== aggregateBySlice ==="); + +{ + const units = [ + makeUnit({ id: "M001/S01/T01", cost: 0.05 }), + makeUnit({ id: "M001/S01/T02", cost: 0.04 }), + makeUnit({ id: "M001/S02/T01", cost: 0.10 }), + makeUnit({ id: "M001", type: "research-milestone", cost: 0.02 }), + ]; + const slices = aggregateBySlice(units); + + assertEq(slices.length, 3, "3 slice groups"); + + const s01 = slices.find(s => s.sliceId === "M001/S01"); + assert(!!s01, "M001/S01 exists"); + assertEq(s01!.units, 2, "M001/S01 has 2 units"); + assertClose(s01!.cost, 0.09, 0.001, "M001/S01 cost"); + + const s02 = slices.find(s => s.sliceId === "M001/S02"); + assert(!!s02, "M001/S02 exists"); + assertEq(s02!.units, 1, "M001/S02 has 1 unit"); + + const mLevel = slices.find(s => s.sliceId === "M001"); + assert(!!mLevel, "M001 (milestone-level) exists"); +} + +// ─── aggregateByModel ───────────────────────────────────────────────────────── + +console.log("\n=== aggregateByModel ==="); + +{ + const units = [ + makeUnit({ model: "claude-sonnet-4-20250514", cost: 0.05 }), + makeUnit({ model: "claude-sonnet-4-20250514", cost: 0.04 }), + makeUnit({ model: "claude-opus-4-20250514", cost: 0.30 }), + ]; + const models = aggregateByModel(units); + + assertEq(models.length, 2, "2 models"); + // Sorted by cost desc — opus should be first + assertEq(models[0].model, "claude-opus-4-20250514", "opus first (higher cost)"); + assertClose(models[0].cost, 0.30, 0.001, "opus cost"); + assertEq(models[1].model, "claude-sonnet-4-20250514", "sonnet second"); + assertEq(models[1].units, 2, "sonnet has 2 units"); +} + +// ─── formatCost ─────────────────────────────────────────────────────────────── + +console.log("\n=== formatCost ==="); + +assertEq(formatCost(0), "$0.0000", "zero cost"); +assertEq(formatCost(0.001), "$0.0010", "sub-cent cost"); +assertEq(formatCost(0.05), "$0.050", "5 cents"); +assertEq(formatCost(1.50), "$1.50", "dollar+"); +assertEq(formatCost(14.20), "$14.20", "double digits"); + +// ─── formatTokenCount ───────────────────────────────────────────────────────── + +console.log("\n=== formatTokenCount ==="); + +assertEq(formatTokenCount(0), "0", "zero tokens"); +assertEq(formatTokenCount(500), "500", "sub-k"); +assertEq(formatTokenCount(1500), "1.5k", "1.5k"); +assertEq(formatTokenCount(150000), "150.0k", "150k"); +assertEq(formatTokenCount(1500000), "1.50M", "1.5M"); + +// ─── Summary ────────────────────────────────────────────────────────────────── + +console.log(`\n${"=".repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} else { + console.log("All tests passed ✓"); +} diff --git a/src/resources/extensions/gsd/tests/must-have-parser.test.ts b/src/resources/extensions/gsd/tests/must-have-parser.test.ts new file mode 100644 index 000000000..2450abecf --- /dev/null +++ b/src/resources/extensions/gsd/tests/must-have-parser.test.ts @@ -0,0 +1,309 @@ +import { parseTaskPlanMustHaves } from '../files.ts'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (a) Standard unchecked format: - [ ] text +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: standard unchecked ==='); +{ + const content = `# T01: Test Task + +## Must-Haves + +- [ ] First must-have item +- [ ] Second must-have item +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 2, 'should return 2 items'); + assertEq(result[0].text, 'First must-have item', 'first item text'); + assertEq(result[0].checked, false, 'first item unchecked'); + assertEq(result[1].text, 'Second must-have item', 'second item text'); + assertEq(result[1].checked, false, 'second item unchecked'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (b) Checked variants: - [x] and - [X] +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: checked [x] and [X] ==='); +{ + const content = `## Must-Haves + +- [x] Lowercase checked item +- [X] Uppercase checked item +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 2, 'should return 2 items'); + assertEq(result[0].checked, true, 'lowercase x is checked'); + assertEq(result[0].text, 'Lowercase checked item', 'lowercase x text'); + assertEq(result[1].checked, true, 'uppercase X is checked'); + assertEq(result[1].text, 'Uppercase checked item', 'uppercase X text'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (c) No-checkbox bullets: - text +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: no-checkbox bullets ==='); +{ + const content = `## Must-Haves + +- Plain bullet item +- Another plain item +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 2, 'should return 2 items'); + assertEq(result[0].text, 'Plain bullet item', 'plain bullet text'); + assertEq(result[0].checked, false, 'plain bullet defaults to unchecked'); + assertEq(result[1].text, 'Another plain item', 'second plain bullet text'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (d) Indented variants +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: indented variants ==='); +{ + const content = `## Must-Haves + + - [ ] Indented unchecked item + - [x] Indented checked item + - Plain indented item +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 3, 'should return 3 items'); + assertEq(result[0].text, 'Indented unchecked item', 'indented unchecked text'); + assertEq(result[0].checked, false, 'indented unchecked state'); + assertEq(result[1].text, 'Indented checked item', 'indented checked text'); + assertEq(result[1].checked, true, 'indented checked state'); + assertEq(result[2].text, 'Plain indented item', 'indented plain text'); + assertEq(result[2].checked, false, 'indented plain state'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (e) Mixed checkbox states in one section +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: mixed states ==='); +{ + const content = `## Must-Haves + +- [ ] Unchecked one +- [x] Checked one +- [X] Also checked +- Plain bullet +- [ ] Another unchecked +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 5, 'should return 5 items'); + assertEq(result[0].checked, false, 'first is unchecked'); + assertEq(result[1].checked, true, 'second is checked'); + assertEq(result[2].checked, true, 'third is checked (uppercase)'); + assertEq(result[3].checked, false, 'fourth (plain) is unchecked'); + assertEq(result[4].checked, false, 'fifth is unchecked'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (f) Missing Must-Haves section → empty array +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: missing section ==='); +{ + const content = `# T01: Some Task + +## Description + +Some description here. + +## Verification + +- Run tests +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 0, 'returns empty array when section missing'); + assert(Array.isArray(result), 'result is an array'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (g) Empty Must-Haves section → empty array +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: empty section ==='); +{ + const content = `## Must-Haves + +## Verification + +- Run tests +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 0, 'returns empty array when section is empty'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (h) Content with YAML frontmatter +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: YAML frontmatter ==='); +{ + const content = `--- +estimated_steps: 5 +estimated_files: 3 +--- + +# T01: Task with frontmatter + +## Must-Haves + +- [ ] Real must-have after frontmatter +- [x] Checked must-have after frontmatter +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 2, 'frontmatter does not pollute results'); + assertEq(result[0].text, 'Real must-have after frontmatter', 'first item text correct'); + assertEq(result[0].checked, false, 'first item unchecked'); + assertEq(result[1].text, 'Checked must-have after frontmatter', 'second item text correct'); + assertEq(result[1].checked, true, 'second item checked'); +} + +// Verify frontmatter content is not misinterpreted as must-haves +console.log('\n=== parseTaskPlanMustHaves: frontmatter-only content ==='); +{ + const content = `--- +estimated_steps: 5 +estimated_files: 3 +--- + +# T01: Task with only frontmatter + +## Description + +No must-haves section here. +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 0, 'frontmatter-only content returns empty array'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// (i) Real task plan format (based on S01/T01-PLAN.md structure) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: real task plan format ==='); +{ + const content = `--- +estimated_steps: 5 +estimated_files: 3 +--- + +# T01: Add completing-milestone phase to deriveState with tests + +**Slice:** S01 — Milestone Completion Unit +**Milestone:** M002 + +## Description + +Add the \`completing-milestone\` phase to the GSD state machine. + +## Steps + +1. Add \`'completing-milestone'\` to the \`Phase\` union type in \`types.ts\`. +2. In \`state.ts\`, modify the registry-building loop. + +## Must-Haves + +- [ ] \`Phase\` type includes \`'completing-milestone'\` +- [ ] \`deriveState\` returns \`phase: 'completing-milestone'\` when all slices are \`[x]\` and no \`M00x-SUMMARY.md\` exists +- [ ] \`deriveState\` returns milestone as \`'complete'\` and advances when summary exists +- [ ] All 63+ existing \`deriveState\` tests pass without modification +- [ ] New test fixtures cover single-milestone and multi-milestone completing-milestone scenarios + +## Verification + +- Run tests +- All existing 63 assertions pass + +## Observability Impact + +- Signals added/changed: \`completing-milestone\` phase now visible +- How a future agent inspects this: Run \`deriveState(basePath)\` +- Failure state exposed: If \`deriveState\` doesn't detect the phase + +## Inputs + +- \`agent/extensions/gsd/types.ts\` — Phase type definition + +## Expected Output + +- \`agent/extensions/gsd/types.ts\` — Phase union includes \`'completing-milestone'\` +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 5, 'real plan has 5 must-haves'); + assert(result[0].text.includes('`Phase` type includes'), 'first must-have text matches'); + assert(result[1].text.includes('`deriveState` returns'), 'second must-have text matches'); + assertEq(result[0].checked, false, 'all real must-haves are unchecked'); + assertEq(result[4].checked, false, 'last real must-have is unchecked'); + assert(result[4].text.includes('multi-milestone'), 'last must-have references multi-milestone'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Edge cases +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseTaskPlanMustHaves: empty string ==='); +{ + const result = parseTaskPlanMustHaves(''); + assertEq(result.length, 0, 'empty string returns empty array'); +} + +console.log('\n=== parseTaskPlanMustHaves: must-haves with inline code and backticks ==='); +{ + const content = `## Must-Haves + +- [ ] \`functionName\` is exported from \`module.ts\` +- [x] Returns \`Array<{ text: string }>\` with correct extraction +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 2, 'handles backtick content'); + assert(result[0].text.includes('`functionName`'), 'preserves backticks in text'); + assertEq(result[0].checked, false, 'backtick item unchecked'); + assertEq(result[1].checked, true, 'backtick item checked'); +} + +console.log('\n=== parseTaskPlanMustHaves: asterisk bullets ==='); +{ + const content = `## Must-Haves + +* [ ] Asterisk unchecked +* [x] Asterisk checked +* Plain asterisk +`; + const result = parseTaskPlanMustHaves(content); + assertEq(result.length, 3, 'handles asterisk bullets'); + assertEq(result[0].checked, false, 'asterisk unchecked'); + assertEq(result[1].checked, true, 'asterisk checked'); + assertEq(result[2].checked, false, 'plain asterisk unchecked'); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts new file mode 100644 index 000000000..0e774ddcb --- /dev/null +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -0,0 +1,1257 @@ +import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts } from '../files.ts'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// parseRoadmap tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseRoadmap: full roadmap ==='); +{ + const content = `# M001: GSD Extension — Hierarchical Planning + +**Vision:** Build a structured planning system for coding agents. + +**Success Criteria:** +- All parsers have test coverage +- Round-trip formatting preserves data +- State derivation works correctly + +--- + +## Slices + +- [x] **S01: Types + File I/O** \`risk:low\` \`depends:[]\` + > After this: All types defined and parsers work. + +- [ ] **S02: State Derivation** \`risk:medium\` \`depends:[S01]\` + > After this: Dashboard shows real-time state. + +- [ ] **S03: Auto Mode** \`risk:high\` \`depends:[S01, S02]\` + > After this: Agent can execute tasks automatically. + +--- + +## Boundary Map + +### S01 → S02 +\`\`\` +Produces: + types.ts — all type definitions + files.ts — parser and formatter functions + +Consumes from S02: + nothing +\`\`\` + +### S02 → S03 +\`\`\` +Produces: + state.ts — deriveState function + +Consumes from S03: + auto-mode entry points +\`\`\` +`; + + const r = parseRoadmap(content); + + assertEq(r.title, 'M001: GSD Extension — Hierarchical Planning', 'roadmap title'); + assertEq(r.vision, 'Build a structured planning system for coding agents.', 'roadmap vision'); + assertEq(r.successCriteria.length, 3, 'success criteria count'); + assertEq(r.successCriteria[0], 'All parsers have test coverage', 'first success criterion'); + assertEq(r.successCriteria[2], 'State derivation works correctly', 'third success criterion'); + + // Slices + assertEq(r.slices.length, 3, 'slice count'); + + assertEq(r.slices[0].id, 'S01', 'S01 id'); + assertEq(r.slices[0].title, 'Types + File I/O', 'S01 title'); + assertEq(r.slices[0].risk, 'low', 'S01 risk'); + assertEq(r.slices[0].depends, [], 'S01 depends'); + assertEq(r.slices[0].done, true, 'S01 done'); + assertEq(r.slices[0].demo, 'All types defined and parsers work.', 'S01 demo'); + + assertEq(r.slices[1].id, 'S02', 'S02 id'); + assertEq(r.slices[1].title, 'State Derivation', 'S02 title'); + assertEq(r.slices[1].risk, 'medium', 'S02 risk'); + assertEq(r.slices[1].depends, ['S01'], 'S02 depends'); + assertEq(r.slices[1].done, false, 'S02 done'); + + assertEq(r.slices[2].id, 'S03', 'S03 id'); + assertEq(r.slices[2].risk, 'high', 'S03 risk'); + assertEq(r.slices[2].depends, ['S01', 'S02'], 'S03 depends'); + assertEq(r.slices[2].done, false, 'S03 done'); + + // Boundary map + assertEq(r.boundaryMap.length, 2, 'boundary map entry count'); + assertEq(r.boundaryMap[0].fromSlice, 'S01', 'bm[0] from'); + assertEq(r.boundaryMap[0].toSlice, 'S02', 'bm[0] to'); + assert(r.boundaryMap[0].produces.includes('types.ts'), 'bm[0] produces mentions types.ts'); + assertEq(r.boundaryMap[1].fromSlice, 'S02', 'bm[1] from'); + assertEq(r.boundaryMap[1].toSlice, 'S03', 'bm[1] to'); +} + +console.log('\n=== parseRoadmap: empty slices section ==='); +{ + const content = `# M002: Empty Milestone + +**Vision:** Nothing yet. + +## Slices + +## Boundary Map +`; + + const r = parseRoadmap(content); + assertEq(r.title, 'M002: Empty Milestone', 'title with empty slices'); + assertEq(r.slices.length, 0, 'no slices parsed'); + assertEq(r.boundaryMap.length, 0, 'no boundary map entries'); +} + +console.log('\n=== parseRoadmap: malformed checkbox lines ==='); +{ + // Lines that don't match the expected bold pattern should be skipped + const content = `# M003: Malformed + +**Vision:** Test malformed lines. + +## Slices + +- [ ] S01: Missing bold markers \`risk:low\` \`depends:[]\` +- [x] **S02: Valid Slice** \`risk:medium\` \`depends:[]\` + > After this: Works. +- [ ] Not a checkbox at all + Some random text +- [x] **S03: Another Valid** \`risk:high\` \`depends:[S02]\` + > After this: Also works. +`; + + const r = parseRoadmap(content); + // Only S02 and S03 should be parsed (malformed lines without bold markers are skipped) + assertEq(r.slices.length, 2, 'only valid slices parsed from malformed input'); + assertEq(r.slices[0].id, 'S02', 'first valid slice is S02'); + assertEq(r.slices[0].done, true, 'S02 done'); + assertEq(r.slices[1].id, 'S03', 'second valid slice is S03'); + assertEq(r.slices[1].depends, ['S02'], 'S03 depends on S02'); +} + +console.log('\n=== parseRoadmap: lowercase vs uppercase X for done ==='); +{ + const content = `# M004: Case Test + +**Vision:** Test X case sensitivity. + +## Slices + +- [x] **S01: Lowercase x** \`risk:low\` \`depends:[]\` + > After this: done. + +- [X] **S02: Uppercase X** \`risk:low\` \`depends:[]\` + > After this: also done. + +- [ ] **S03: Not Done** \`risk:low\` \`depends:[]\` + > After this: not yet. +`; + + const r = parseRoadmap(content); + assertEq(r.slices.length, 3, 'all three slices parsed'); + assertEq(r.slices[0].done, true, 'lowercase x is done'); + assertEq(r.slices[1].done, true, 'uppercase X is done'); + assertEq(r.slices[2].done, false, 'space is not done'); +} + +console.log('\n=== parseRoadmap: missing boundary map ==='); +{ + const content = `# M005: No Boundary Map + +**Vision:** A roadmap without a boundary map section. + +**Success Criteria:** +- One criterion + +--- + +## Slices + +- [ ] **S01: Only Slice** \`risk:low\` \`depends:[]\` + > After this: Done. +`; + + const r = parseRoadmap(content); + assertEq(r.title, 'M005: No Boundary Map', 'title'); + assertEq(r.slices.length, 1, 'one slice'); + assertEq(r.boundaryMap.length, 0, 'empty boundary map when section missing'); + assertEq(r.successCriteria.length, 1, 'one success criterion'); +} + +console.log('\n=== parseRoadmap: no sections at all ==='); +{ + const content = `# M006: Bare Minimum + +Just a title and nothing else. +`; + + const r = parseRoadmap(content); + assertEq(r.title, 'M006: Bare Minimum', 'title from bare roadmap'); + assertEq(r.vision, '', 'empty vision'); + assertEq(r.successCriteria.length, 0, 'no success criteria'); + assertEq(r.slices.length, 0, 'no slices'); + assertEq(r.boundaryMap.length, 0, 'no boundary map'); +} + +console.log('\n=== parseRoadmap: slice with no demo blockquote ==='); +{ + const content = `# M007: No Demo + +**Vision:** Testing slices without demo lines. + +## Slices + +- [ ] **S01: No Demo Here** \`risk:medium\` \`depends:[]\` +- [ ] **S02: Also No Demo** \`risk:low\` \`depends:[S01]\` +`; + + const r = parseRoadmap(content); + assertEq(r.slices.length, 2, 'two slices without demos'); + assertEq(r.slices[0].demo, '', 'S01 demo empty'); + assertEq(r.slices[1].demo, '', 'S02 demo empty'); +} + +console.log('\n=== parseRoadmap: missing risk defaults to low ==='); +{ + const content = `# M008: Default Risk + +**Vision:** Test default risk. + +## Slices + +- [ ] **S01: No Risk Tag** \`depends:[]\` + > After this: done. +`; + + const r = parseRoadmap(content); + assertEq(r.slices.length, 1, 'one slice'); + assertEq(r.slices[0].risk, 'low', 'default risk is low'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// parsePlan tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parsePlan: full plan ==='); +{ + const content = `# S01: Parser Test Suite + +**Goal:** All 5 parsers have test coverage with edge cases. +**Demo:** \`node --test tests/parsers.test.ts\` passes with zero failures. + +## Must-Haves + +- parseRoadmap tests cover happy path and edge cases +- parsePlan tests cover happy path and edge cases +- All existing tests still pass + +## Tasks + +- [ ] **T01: Test parseRoadmap and parsePlan** \`est:45m\` + Create tests/parsers.test.ts with comprehensive tests for the two most complex parsers. + +- [x] **T02: Test parseSummary and parseContinue** \`est:35m\` + Extend tests/parsers.test.ts with tests for the remaining parsers. + +## Files Likely Touched + +- \`tests/parsers.test.ts\` — new test file +- \`types.ts\` — add observability_surfaces +- \`files.ts\` — update parseSummary +`; + + const p = parsePlan(content); + + assertEq(p.id, 'S01', 'plan id'); + assertEq(p.title, 'Parser Test Suite', 'plan title'); + assertEq(p.goal, 'All 5 parsers have test coverage with edge cases.', 'plan goal'); + assertEq(p.demo, '`node --test tests/parsers.test.ts` passes with zero failures.', 'plan demo'); + + // Must-haves + assertEq(p.mustHaves.length, 3, 'must-have count'); + assertEq(p.mustHaves[0], 'parseRoadmap tests cover happy path and edge cases', 'first must-have'); + + // Tasks + assertEq(p.tasks.length, 2, 'task count'); + + assertEq(p.tasks[0].id, 'T01', 'T01 id'); + assertEq(p.tasks[0].title, 'Test parseRoadmap and parsePlan', 'T01 title'); + assertEq(p.tasks[0].done, false, 'T01 not done'); + assert(p.tasks[0].description.includes('comprehensive tests'), 'T01 description content'); + + assertEq(p.tasks[1].id, 'T02', 'T02 id'); + assertEq(p.tasks[1].title, 'Test parseSummary and parseContinue', 'T02 title'); + assertEq(p.tasks[1].done, true, 'T02 done'); + + // Files likely touched + assertEq(p.filesLikelyTouched.length, 3, 'files likely touched count'); + assert(p.filesLikelyTouched[0].includes('tests/parsers.test.ts'), 'first file'); +} + +console.log('\n=== parsePlan: multi-line task description concatenation ==='); +{ + const content = `# S02: Multi-line Test + +**Goal:** Test multi-line descriptions. +**Demo:** Descriptions are concatenated. + +## Must-Haves + +- Multi-line works + +## Tasks + +- [ ] **T01: Multi-line Task** \`est:30m\` + First line of description. + Second line of description. + Third line of description. + +- [ ] **T02: Single Line** \`est:10m\` + Just one line. + +## Files Likely Touched + +- \`foo.ts\` +`; + + const p = parsePlan(content); + + assertEq(p.tasks.length, 2, 'two tasks'); + // Multi-line descriptions should be concatenated with spaces + assert(p.tasks[0].description.includes('First line'), 'T01 desc has first line'); + assert(p.tasks[0].description.includes('Second line'), 'T01 desc has second line'); + assert(p.tasks[0].description.includes('Third line'), 'T01 desc has third line'); + // Verify concatenation with space separator + assert(p.tasks[0].description.includes('description. Second'), 'lines joined with space'); + + assertEq(p.tasks[1].description, 'Just one line.', 'T02 single-line desc'); +} + +console.log('\n=== parsePlan: task with missing estimate ==='); +{ + const content = `# S03: No Estimate + +**Goal:** Handle tasks without estimates. +**Demo:** Parser doesn't crash. + +## Tasks + +- [ ] **T01: No Estimate Task** + A task without an estimate backtick. + +- [ ] **T02: Has Estimate** \`est:20m\` + This one has an estimate. +`; + + const p = parsePlan(content); + + assertEq(p.tasks.length, 2, 'two tasks parsed'); + assertEq(p.tasks[0].id, 'T01', 'T01 id'); + assertEq(p.tasks[0].title, 'No Estimate Task', 'T01 title without estimate'); + assertEq(p.tasks[0].done, false, 'T01 not done'); + // The estimate backtick text appears in description if present, but parser doesn't crash without it + assertEq(p.tasks[1].id, 'T02', 'T02 id'); +} + +console.log('\n=== parsePlan: empty tasks section ==='); +{ + const content = `# S04: Empty Tasks + +**Goal:** No tasks yet. +**Demo:** Nothing. + +## Must-Haves + +- Something + +## Tasks + +## Files Likely Touched + +- \`nothing.ts\` +`; + + const p = parsePlan(content); + + assertEq(p.id, 'S04', 'plan id with empty tasks'); + assertEq(p.tasks.length, 0, 'no tasks'); + assertEq(p.mustHaves.length, 1, 'one must-have'); + assertEq(p.filesLikelyTouched.length, 1, 'one file'); +} + +console.log('\n=== parsePlan: no H1 ==='); +{ + const content = `**Goal:** A plan without a heading. +**Demo:** Still parses. + +## Tasks + +- [ ] **T01: Orphan Task** \`est:5m\` + A task in a headingless plan. +`; + + const p = parsePlan(content); + + assertEq(p.id, '', 'empty id without H1'); + assertEq(p.title, '', 'empty title without H1'); + assertEq(p.goal, 'A plan without a heading.', 'goal still parsed'); + assertEq(p.tasks.length, 1, 'task still parsed'); + assertEq(p.tasks[0].id, 'T01', 'task id'); +} + +console.log('\n=== parsePlan: task estimate backtick in description ==='); +{ + // The `est:45m` text appears after the bold closing but before the description lines + // It should end up as part of the description or be ignored gracefully + const content = `# S05: Estimate Handling + +**Goal:** Test estimate text handling. +**Demo:** Works. + +## Tasks + +- [ ] **T01: With Estimate** \`est:45m\` + Main description here. +`; + + const p = parsePlan(content); + assertEq(p.tasks.length, 1, 'one task'); + assertEq(p.tasks[0].id, 'T01', 'task id'); + assertEq(p.tasks[0].title, 'With Estimate', 'title excludes estimate'); + // The `est:45m` backtick text after ** is not part of the title or description + // It's on the same line after the regex match captures, so it's in the remainder + // The description should be the continuation lines + assert(p.tasks[0].description.includes('Main description'), 'description from continuation line'); +} + +console.log('\n=== parsePlan: uppercase X for done ==='); +{ + const content = `# S06: Case Test + +**Goal:** Test case. +**Demo:** Works. + +## Tasks + +- [X] **T01: Uppercase Done** \`est:5m\` + Done with uppercase X. + +- [x] **T02: Lowercase Done** \`est:5m\` + Done with lowercase x. +`; + + const p = parsePlan(content); + assertEq(p.tasks[0].done, true, 'uppercase X is done'); + assertEq(p.tasks[1].done, true, 'lowercase x is done'); +} + +console.log('\n=== parsePlan: no Must-Haves section ==='); +{ + const content = `# S07: No Must-Haves + +**Goal:** Test missing must-haves. +**Demo:** Parser handles it. + +## Tasks + +- [ ] **T01: Only Task** \`est:10m\` + The only task. +`; + + const p = parsePlan(content); + assertEq(p.mustHaves.length, 0, 'empty must-haves'); + assertEq(p.tasks.length, 1, 'task still parsed'); +} + +console.log('\n=== parsePlan: no Files Likely Touched section ==='); +{ + const content = `# S08: No Files + +**Goal:** Test missing files section. +**Demo:** Parser handles it. + +## Tasks + +- [ ] **T01: Task** \`est:10m\` + Description. +`; + + const p = parsePlan(content); + assertEq(p.filesLikelyTouched.length, 0, 'empty files likely touched'); +} + +console.log('\n=== parsePlan: old-format task entries (no sublines) ==='); +{ + const content = `# S09: Old Format + +**Goal:** Test old-format compatibility. +**Demo:** Parser handles entries without sublines. + +## Tasks + +- [ ] **T01: Classic Task** \`est:10m\` + Just a plain description with no labeled sublines. +`; + + const p = parsePlan(content); + assertEq(p.tasks.length, 1, 'one task parsed'); + assertEq(p.tasks[0].id, 'T01', 'task id'); + assertEq(p.tasks[0].title, 'Classic Task', 'task title'); + assertEq(p.tasks[0].done, false, 'task not done'); + assertEq(p.tasks[0].files, undefined, 'files is undefined for old-format entry'); + assertEq(p.tasks[0].verify, undefined, 'verify is undefined for old-format entry'); +} + +console.log('\n=== parsePlan: new-format task entries with Files and Verify sublines ==='); +{ + const content = `# S10: New Format + +**Goal:** Test new-format subline extraction. +**Demo:** Parser extracts Files and Verify correctly. + +## Tasks + +- [ ] **T01: Modern Task** \`est:15m\` + - Why: because we need typed plan entries + - Files: \`types.ts\`, \`files.ts\` + - Verify: run the test suite +`; + + const p = parsePlan(content); + assertEq(p.tasks.length, 1, 'one task parsed'); + assertEq(p.tasks[0].id, 'T01', 'task id'); + assert(Array.isArray(p.tasks[0].files), 'files is an array'); + assertEq(p.tasks[0].files!.length, 2, 'files array has two entries'); + assertEq(p.tasks[0].files![0], 'types.ts', 'first file is types.ts'); + assertEq(p.tasks[0].files![1], 'files.ts', 'second file is files.ts'); + assertEq(p.tasks[0].verify, 'run the test suite', 'verify string extracted correctly'); + assert(p.tasks[0].description.includes('Why: because we need typed plan entries'), 'Why line accumulates into description'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// parseSummary tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseSummary: full summary with all frontmatter fields ==='); +{ + const content = `--- +id: T01 +parent: S01 +milestone: M001 +provides: + - parseRoadmap test coverage + - parsePlan test coverage +requires: + - slice: S00 + provides: type definitions + - slice: S02 + provides: state derivation +affects: + - auto-mode dispatch +key_files: + - tests/parsers.test.ts + - files.ts +key_decisions: + - Use manual assert pattern +patterns_established: + - parsers.test.ts is the canonical test location +drill_down_paths: + - tests/parsers.test.ts for assertion details +observability_surfaces: + - test pass/fail output from node --test + - exit code 1 on failure +duration: 23min +verification_result: pass +retries: 0 +completed_at: 2025-03-10T08:00:00Z +--- + +# T01: Test parseRoadmap and parsePlan + +**Created parsers.test.ts with 98 assertions across 16 test groups.** + +## What Happened + +Added comprehensive tests for parseRoadmap and parsePlan. + +## Deviations + +None. + +## Files Created/Modified + +- \`tests/parsers.test.ts\` — new test file with 98 assertions +- \`types.ts\` — added observability_surfaces field +- \`files.ts\` — updated parseSummary extraction +`; + + const s = parseSummary(content); + + // Frontmatter fields + assertEq(s.frontmatter.id, 'T01', 'summary id'); + assertEq(s.frontmatter.parent, 'S01', 'summary parent'); + assertEq(s.frontmatter.milestone, 'M001', 'summary milestone'); + assertEq(s.frontmatter.provides.length, 2, 'provides count'); + assertEq(s.frontmatter.provides[0], 'parseRoadmap test coverage', 'first provides'); + assertEq(s.frontmatter.provides[1], 'parsePlan test coverage', 'second provides'); + + // requires (nested objects) + assertEq(s.frontmatter.requires.length, 2, 'requires count'); + assertEq(s.frontmatter.requires[0].slice, 'S00', 'first requires slice'); + assertEq(s.frontmatter.requires[0].provides, 'type definitions', 'first requires provides'); + assertEq(s.frontmatter.requires[1].slice, 'S02', 'second requires slice'); + assertEq(s.frontmatter.requires[1].provides, 'state derivation', 'second requires provides'); + + assertEq(s.frontmatter.affects.length, 1, 'affects count'); + assertEq(s.frontmatter.affects[0], 'auto-mode dispatch', 'affects value'); + assertEq(s.frontmatter.key_files.length, 2, 'key_files count'); + assertEq(s.frontmatter.key_decisions.length, 1, 'key_decisions count'); + assertEq(s.frontmatter.patterns_established.length, 1, 'patterns_established count'); + assertEq(s.frontmatter.drill_down_paths.length, 1, 'drill_down_paths count'); + + // observability_surfaces extraction + assertEq(s.frontmatter.observability_surfaces.length, 2, 'observability_surfaces count'); + assertEq(s.frontmatter.observability_surfaces[0], 'test pass/fail output from node --test', 'first observability surface'); + assertEq(s.frontmatter.observability_surfaces[1], 'exit code 1 on failure', 'second observability surface'); + + assertEq(s.frontmatter.duration, '23min', 'duration'); + assertEq(s.frontmatter.verification_result, 'pass', 'verification_result'); + assertEq(s.frontmatter.completed_at, '2025-03-10T08:00:00Z', 'completed_at'); + + // Body fields + assertEq(s.title, 'T01: Test parseRoadmap and parsePlan', 'summary title'); + assertEq(s.oneLiner, 'Created parsers.test.ts with 98 assertions across 16 test groups.', 'one-liner'); + assert(s.whatHappened.includes('comprehensive tests'), 'whatHappened content'); + assertEq(s.deviations, 'None.', 'deviations'); + + // Files modified + assertEq(s.filesModified.length, 3, 'filesModified count'); + assertEq(s.filesModified[0].path, 'tests/parsers.test.ts', 'first file path'); + assert(s.filesModified[0].description.includes('98 assertions'), 'first file description'); + assertEq(s.filesModified[1].path, 'types.ts', 'second file path'); + assertEq(s.filesModified[2].path, 'files.ts', 'third file path'); +} + +console.log('\n=== parseSummary: one-liner extraction (bold-wrapped line after H1) ==='); +{ + const content = `# S01: Parser Test Suite + +**All 5 parsers have test coverage with edge cases.** + +## What Happened + +Things happened. +`; + + const s = parseSummary(content); + assertEq(s.title, 'S01: Parser Test Suite', 'title'); + assertEq(s.oneLiner, 'All 5 parsers have test coverage with edge cases.', 'bold one-liner'); +} + +console.log('\n=== parseSummary: non-bold paragraph after H1 (empty one-liner) ==='); +{ + const content = `# T02: Some Task + +This is just a regular paragraph, not bold. + +## What Happened + +Did stuff. +`; + + const s = parseSummary(content); + assertEq(s.title, 'T02: Some Task', 'title'); + assertEq(s.oneLiner, '', 'non-bold line results in empty one-liner'); +} + +console.log('\n=== parseSummary: files-modified parsing (backtick path — description format) ==='); +{ + const content = `# T03: File Changes + +**One-liner.** + +## Files Created/Modified + +- \`src/index.ts\` — main entry point +- \`src/utils.ts\` — utility functions +- \`README.md\` — updated docs +`; + + const s = parseSummary(content); + assertEq(s.filesModified.length, 3, 'three files'); + assertEq(s.filesModified[0].path, 'src/index.ts', 'first path'); + assertEq(s.filesModified[0].description, 'main entry point', 'first description'); + assertEq(s.filesModified[1].path, 'src/utils.ts', 'second path'); + assertEq(s.filesModified[2].path, 'README.md', 'third path'); +} + +console.log('\n=== parseSummary: missing frontmatter (safe defaults) ==='); +{ + const content = `# T04: No Frontmatter + +**Did something.** + +## What Happened + +No frontmatter at all. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.id, '', 'default id empty'); + assertEq(s.frontmatter.parent, '', 'default parent empty'); + assertEq(s.frontmatter.milestone, '', 'default milestone empty'); + assertEq(s.frontmatter.provides.length, 0, 'default provides empty'); + assertEq(s.frontmatter.requires.length, 0, 'default requires empty'); + assertEq(s.frontmatter.affects.length, 0, 'default affects empty'); + assertEq(s.frontmatter.key_files.length, 0, 'default key_files empty'); + assertEq(s.frontmatter.key_decisions.length, 0, 'default key_decisions empty'); + assertEq(s.frontmatter.patterns_established.length, 0, 'default patterns_established empty'); + assertEq(s.frontmatter.drill_down_paths.length, 0, 'default drill_down_paths empty'); + assertEq(s.frontmatter.observability_surfaces.length, 0, 'default observability_surfaces empty'); + assertEq(s.frontmatter.duration, '', 'default duration empty'); + assertEq(s.frontmatter.verification_result, 'untested', 'default verification_result'); + assertEq(s.frontmatter.completed_at, '', 'default completed_at empty'); + assertEq(s.title, 'T04: No Frontmatter', 'title still parsed'); + assertEq(s.oneLiner, 'Did something.', 'one-liner still parsed'); +} + +console.log('\n=== parseSummary: empty body ==='); +{ + const content = `--- +id: T05 +parent: S01 +milestone: M001 +--- +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.id, 'T05', 'id from frontmatter'); + assertEq(s.title, '', 'empty title'); + assertEq(s.oneLiner, '', 'empty one-liner'); + assertEq(s.whatHappened, '', 'empty whatHappened'); + assertEq(s.deviations, '', 'empty deviations'); + assertEq(s.filesModified.length, 0, 'no files modified'); +} + +console.log('\n=== parseSummary: summary with requires array (nested objects) ==='); +{ + const content = `--- +id: T06 +parent: S02 +milestone: M001 +requires: + - slice: S01 + provides: parser functions + - slice: S00 + provides: core types + - slice: S03 + provides: state engine +provides: [] +affects: [] +key_files: [] +key_decisions: [] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: 10min +verification_result: pass +retries: 1 +completed_at: 2025-03-10T09:00:00Z +--- + +# T06: Nested Requires + +**Test nested requires parsing.** + +## What Happened + +Tested. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.requires.length, 3, 'three requires entries'); + assertEq(s.frontmatter.requires[0].slice, 'S01', 'first requires slice'); + assertEq(s.frontmatter.requires[0].provides, 'parser functions', 'first requires provides'); + assertEq(s.frontmatter.requires[1].slice, 'S00', 'second requires slice'); + assertEq(s.frontmatter.requires[2].slice, 'S03', 'third requires slice'); + assertEq(s.frontmatter.requires[2].provides, 'state engine', 'third requires provides'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// parseContinue tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseContinue: full continue file with all frontmatter fields ==='); +{ + const content = `--- +milestone: M001 +slice: S01 +task: T02 +step: 3 +total_steps: 5 +status: in_progress +saved_at: 2025-03-10T08:30:00Z +--- + +## Completed Work + +Steps 1-3 are done. Created test file and wrote assertions. + +## Remaining Work + +Steps 4-5: run tests and check regressions. + +## Decisions Made + +Used manual assert pattern instead of node:assert. + +## Context + +Working in the gsd-s01 worktree. All imports use .ts extensions. + +## Next Action + +Run the full test suite with node --test. +`; + + const c = parseContinue(content); + + // Frontmatter + assertEq(c.frontmatter.milestone, 'M001', 'continue milestone'); + assertEq(c.frontmatter.slice, 'S01', 'continue slice'); + assertEq(c.frontmatter.task, 'T02', 'continue task'); + assertEq(c.frontmatter.step, 3, 'continue step'); + assertEq(c.frontmatter.totalSteps, 5, 'continue totalSteps'); + assertEq(c.frontmatter.status, 'in_progress', 'continue status'); + assertEq(c.frontmatter.savedAt, '2025-03-10T08:30:00Z', 'continue savedAt'); + + // Body sections + assert(c.completedWork.includes('Steps 1-3 are done'), 'completedWork content'); + assert(c.remainingWork.includes('Steps 4-5'), 'remainingWork content'); + assert(c.decisions.includes('manual assert pattern'), 'decisions content'); + assert(c.context.includes('gsd-s01 worktree'), 'context content'); + assert(c.nextAction.includes('node --test'), 'nextAction content'); +} + +console.log('\n=== parseContinue: string step/totalSteps parsed as integers ==='); +{ + const content = `--- +milestone: M002 +slice: S03 +task: T01 +step: 7 +total_steps: 12 +status: in_progress +saved_at: 2025-03-10T10:00:00Z +--- + +## Completed Work + +Some work. + +## Remaining Work + +More work. + +## Decisions Made + +None. + +## Context + +None. + +## Next Action + +Continue. +`; + + const c = parseContinue(content); + assertEq(c.frontmatter.step, 7, 'step parsed as integer 7'); + assertEq(c.frontmatter.totalSteps, 12, 'totalSteps parsed as integer 12'); + assertEq(typeof c.frontmatter.step, 'number', 'step is number type'); + assertEq(typeof c.frontmatter.totalSteps, 'number', 'totalSteps is number type'); +} + +console.log('\n=== parseContinue: NaN step values (non-numeric strings) ==='); +{ + const content = `--- +milestone: M001 +slice: S01 +task: T01 +step: abc +total_steps: xyz +status: in_progress +saved_at: 2025-03-10T10:00:00Z +--- + +## Completed Work + +Work. + +## Remaining Work + +Work. + +## Decisions Made + +None. + +## Context + +None. + +## Next Action + +Do things. +`; + + const c = parseContinue(content); + // parseInt("abc") returns NaN; the parser || 0 fallback should give 0 + // Actually, looking at parser: typeof fm.step === 'string' ? parseInt(fm.step) : ... + // parseInt("abc") = NaN, and NaN || 0 doesn't work because NaN is falsy only in boolean context + // But the parser uses: typeof fm.step === 'string' ? parseInt(fm.step) : (fm.step as number) || 0 + // parseInt returns NaN which is a number, not 0 — let's verify + const stepIsNaN = Number.isNaN(c.frontmatter.step); + const totalIsNaN = Number.isNaN(c.frontmatter.totalSteps); + // The parser does parseInt which returns NaN for non-numeric strings + // There's no || 0 fallback on the parseInt path, so NaN is expected + assert(stepIsNaN, 'NaN step when non-numeric string'); + assert(totalIsNaN, 'NaN totalSteps when non-numeric string'); +} + +console.log('\n=== parseContinue: all three status variants ==='); +{ + for (const status of ['in_progress', 'interrupted', 'compacted'] as const) { + const content = `--- +milestone: M001 +slice: S01 +task: T01 +step: 1 +total_steps: 3 +status: ${status} +saved_at: 2025-03-10T10:00:00Z +--- + +## Completed Work + +Work. +`; + + const c = parseContinue(content); + assertEq(c.frontmatter.status, status, `status variant: ${status}`); + } +} + +console.log('\n=== parseContinue: missing frontmatter ==='); +{ + const content = `## Completed Work + +Some work done. + +## Remaining Work + +More to do. + +## Decisions Made + +A decision. + +## Context + +Some context. + +## Next Action + +Next thing. +`; + + const c = parseContinue(content); + assertEq(c.frontmatter.milestone, '', 'default milestone empty'); + assertEq(c.frontmatter.slice, '', 'default slice empty'); + assertEq(c.frontmatter.task, '', 'default task empty'); + assertEq(c.frontmatter.step, 0, 'default step 0'); + assertEq(c.frontmatter.totalSteps, 0, 'default totalSteps 0'); + assertEq(c.frontmatter.status, 'in_progress', 'default status in_progress'); + assertEq(c.frontmatter.savedAt, '', 'default savedAt empty'); + + // Body sections still parse + assert(c.completedWork.includes('Some work done'), 'completedWork without frontmatter'); + assert(c.remainingWork.includes('More to do'), 'remainingWork without frontmatter'); + assert(c.decisions.includes('A decision'), 'decisions without frontmatter'); + assert(c.context.includes('Some context'), 'context without frontmatter'); + assert(c.nextAction.includes('Next thing'), 'nextAction without frontmatter'); +} + +console.log('\n=== parseContinue: body section extraction ==='); +{ + const content = `--- +milestone: M001 +slice: S01 +task: T03 +step: 2 +total_steps: 4 +status: interrupted +saved_at: 2025-03-10T11:00:00Z +--- + +## Completed Work + +First paragraph of completed work. +Second paragraph continuing the explanation. + +## Remaining Work + +Need to finish step 3 and step 4. + +## Decisions Made + +Decided to use approach A over approach B because of performance. + +## Context + +Running in worktree. Node 22 required. TypeScript strict mode. + +## Next Action + +Pick up at step 3: run the integration tests. +`; + + const c = parseContinue(content); + assert(c.completedWork.includes('First paragraph'), 'completedWork first paragraph'); + assert(c.completedWork.includes('Second paragraph'), 'completedWork second paragraph'); + assert(c.remainingWork.includes('step 3 and step 4'), 'remainingWork detail'); + assert(c.decisions.includes('approach A over approach B'), 'decisions detail'); + assert(c.context.includes('Node 22 required'), 'context detail'); + assert(c.nextAction.includes('step 3: run the integration tests'), 'nextAction detail'); +} + +console.log('\n=== parseContinue: total_steps vs totalSteps key support ==='); +{ + // Test total_steps (snake_case) — the primary format + const content1 = `--- +milestone: M001 +slice: S01 +task: T01 +step: 2 +total_steps: 8 +status: in_progress +saved_at: 2025-03-10T12:00:00Z +--- + +## Completed Work + +Work. +`; + + const c1 = parseContinue(content1); + assertEq(c1.frontmatter.totalSteps, 8, 'total_steps snake_case works'); + + // Test totalSteps (camelCase) — the fallback + const content2 = `--- +milestone: M001 +slice: S01 +task: T01 +step: 2 +totalSteps: 6 +status: in_progress +saved_at: 2025-03-10T12:00:00Z +--- + +## Completed Work + +Work. +`; + + const c2 = parseContinue(content2); + assertEq(c2.frontmatter.totalSteps, 6, 'totalSteps camelCase works'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// parseRequirementCounts tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseRequirementCounts: full requirements file ==='); +{ + const content = `# Requirements + +## Active + +### R001 — User authentication +- Status: active + +### R002 — Dashboard rendering +- Status: blocked + +### R003 — API rate limiting +- Status: active + +## Validated + +### R010 — Parser test coverage +- Status: validated + +### R011 — Type system +- Status: validated + +## Deferred + +### R020 — Admin panel +- Status: deferred + +## Out of Scope + +### R030 — Mobile app +- Status: out-of-scope + +### R031 — Desktop app +- Status: out-of-scope +`; + + const counts = parseRequirementCounts(content); + assertEq(counts.active, 3, 'active count'); + assertEq(counts.validated, 2, 'validated count'); + assertEq(counts.deferred, 1, 'deferred count'); + assertEq(counts.outOfScope, 2, 'outOfScope count'); + assertEq(counts.blocked, 1, 'blocked count'); + assertEq(counts.total, 8, 'total is sum of active+validated+deferred+outOfScope'); +} + +console.log('\n=== parseRequirementCounts: null input returns all zeros ==='); +{ + const counts = parseRequirementCounts(null); + assertEq(counts.active, 0, 'null active'); + assertEq(counts.validated, 0, 'null validated'); + assertEq(counts.deferred, 0, 'null deferred'); + assertEq(counts.outOfScope, 0, 'null outOfScope'); + assertEq(counts.blocked, 0, 'null blocked'); + assertEq(counts.total, 0, 'null total'); +} + +console.log('\n=== parseRequirementCounts: empty sections return zero counts ==='); +{ + const content = `# Requirements + +## Active + +## Validated + +## Deferred + +## Out of Scope +`; + + const counts = parseRequirementCounts(content); + assertEq(counts.active, 0, 'empty active'); + assertEq(counts.validated, 0, 'empty validated'); + assertEq(counts.deferred, 0, 'empty deferred'); + assertEq(counts.outOfScope, 0, 'empty outOfScope'); + assertEq(counts.blocked, 0, 'empty blocked'); + assertEq(counts.total, 0, 'empty total'); +} + +console.log('\n=== parseRequirementCounts: blocked status counting ==='); +{ + const content = `# Requirements + +## Active + +### R001 — Blocked thing +- Status: blocked + +### R002 — Another blocked thing +- Status: blocked + +### R003 — Active thing +- Status: active + +## Validated + +## Deferred + +### R020 — Blocked deferred +- Status: blocked + +## Out of Scope +`; + + const counts = parseRequirementCounts(content); + assertEq(counts.active, 3, 'active includes blocked items in Active section'); + assertEq(counts.blocked, 3, 'blocked counts all blocked statuses across sections'); + assertEq(counts.deferred, 1, 'deferred section count'); +} + +console.log('\n=== parseRequirementCounts: total is sum of all section counts ==='); +{ + const content = `# Requirements + +## Active + +### R001 — One +- Status: active + +## Validated + +### R010 — Two +- Status: validated + +### R011 — Three +- Status: validated + +## Deferred + +### R020 — Four +- Status: deferred + +### R021 — Five +- Status: deferred + +### R022 — Six +- Status: deferred + +## Out of Scope + +### R030 — Seven +- Status: out-of-scope +`; + + const counts = parseRequirementCounts(content); + assertEq(counts.active, 1, 'one active'); + assertEq(counts.validated, 2, 'two validated'); + assertEq(counts.deferred, 3, 'three deferred'); + assertEq(counts.outOfScope, 1, 'one outOfScope'); + assertEq(counts.total, 7, 'total = 1 + 2 + 3 + 1'); + assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Results +// ═══════════════════════════════════════════════════════════════════════════ + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✓'); diff --git a/src/resources/extensions/gsd/tests/plan-milestone.test.ts b/src/resources/extensions/gsd/tests/plan-milestone.test.ts new file mode 100644 index 000000000..aae11ca99 --- /dev/null +++ b/src/resources/extensions/gsd/tests/plan-milestone.test.ts @@ -0,0 +1,163 @@ +// Tests for inlinePriorMilestoneSummary — the cross-milestone context bridging helper. +// +// Scenarios covered: +// (A) M002 with M001-SUMMARY.md present → returns string containing "Prior Milestone Summary" and summary content +// (B) M001 (no prior milestone in dir) → returns null +// (C) M002 with no M001-SUMMARY.md written → returns null +// (D) M003 with M002 dir present but no M002-SUMMARY.md → returns null + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { inlinePriorMilestoneSummary } from '../files.ts'; + +// ─── Worktree-aware prompt loader ────────────────────────────────────────── +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ─── Assertion helpers ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-plan-ms-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeMilestoneDir(base: string, mid: string): void { + mkdirSync(join(base, '.gsd', 'milestones', mid), { recursive: true }); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── (A) M002 with M001-SUMMARY.md present ──────────────────────────────── + console.log('\n── (A) M002 with M001-SUMMARY.md present → string containing "Prior Milestone Summary"'); + { + const base = createFixtureBase(); + try { + writeMilestoneDir(base, 'M001'); + writeMilestoneDir(base, 'M002'); + writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nKey decisions: used TypeScript throughout.\n'); + + const result = await inlinePriorMilestoneSummary('M002', base); + + assert(result !== null, '(A) result is not null when prior milestone has SUMMARY'); + assert( + typeof result === 'string' && result.includes('Prior Milestone Summary'), + '(A) result contains "Prior Milestone Summary" label', + ); + assert( + typeof result === 'string' && result.includes('Key decisions: used TypeScript throughout.'), + '(A) result contains the summary file content', + ); + } finally { + cleanup(base); + } + } + + // ─── (B) M001 (no prior milestone in dir) ───────────────────────────────── + console.log('\n── (B) M001 — first milestone, no prior → null'); + { + const base = createFixtureBase(); + try { + writeMilestoneDir(base, 'M001'); + + const result = await inlinePriorMilestoneSummary('M001', base); + + assertEq(result, null, '(B) M001 with no prior milestone → null'); + } finally { + cleanup(base); + } + } + + // ─── (C) M002 with no M001-SUMMARY.md ──────────────────────────────────── + console.log('\n── (C) M002 with M001 dir but no M001-SUMMARY.md → null'); + { + const base = createFixtureBase(); + try { + writeMilestoneDir(base, 'M001'); + writeMilestoneDir(base, 'M002'); + // Intentionally do NOT write M001-SUMMARY.md + + const result = await inlinePriorMilestoneSummary('M002', base); + + assertEq(result, null, '(C) M002 when M001 has no SUMMARY file → null'); + } finally { + cleanup(base); + } + } + + // ─── (D) M003 with M002 dir but no M002-SUMMARY.md ─────────────────────── + console.log('\n── (D) M003, M002 is immediately prior but has no SUMMARY → null'); + { + const base = createFixtureBase(); + try { + writeMilestoneDir(base, 'M001'); + writeMilestoneDir(base, 'M002'); + writeMilestoneDir(base, 'M003'); + // M001 has a summary — but M002 (the immediately prior to M003) does NOT + writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nOld context.\n'); + // Intentionally do NOT write M002-SUMMARY.md + + const result = await inlinePriorMilestoneSummary('M003', base); + + assertEq(result, null, '(D) M003 when M002 (immediately prior) has no SUMMARY → null'); + } finally { + cleanup(base); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Results + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed ✓'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts b/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts new file mode 100644 index 000000000..e78e877c5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts @@ -0,0 +1,386 @@ +import { validateTaskPlanContent, validateSlicePlanContent } from '../observability-validator.ts'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateTaskPlanContent — empty/missing Steps section +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateTaskPlanContent: empty Steps section ==='); +{ + const content = `# T01: Some Task + +## Description + +Do something useful. + +## Steps + +## Verification + +- Run the tests and confirm output. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const stepsIssues = issues.filter(i => i.ruleId === 'empty_steps_section'); + assert(stepsIssues.length >= 1, 'empty Steps section produces empty_steps_section issue'); + if (stepsIssues.length > 0) { + assertEq(stepsIssues[0].severity, 'warning', 'empty_steps_section severity is warning'); + assertEq(stepsIssues[0].scope, 'task-plan', 'empty_steps_section scope is task-plan'); + } +} + +console.log('\n=== validateTaskPlanContent: missing Steps section entirely ==='); +{ + const content = `# T01: Some Task + +## Description + +Do something useful. + +## Verification + +- Run the tests. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const stepsIssues = issues.filter(i => i.ruleId === 'empty_steps_section'); + assert(stepsIssues.length >= 1, 'missing Steps section produces empty_steps_section issue'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateTaskPlanContent — placeholder-only Verification +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateTaskPlanContent: placeholder-only Verification ==='); +{ + const content = `# T01: Some Task + +## Steps + +1. Do the thing. +2. Do the other thing. + +## Verification + +- {{placeholder verification step}} +- {{another placeholder}} +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const verifyIssues = issues.filter(i => i.ruleId === 'placeholder_verification'); + assert(verifyIssues.length >= 1, 'placeholder-only Verification produces placeholder_verification issue'); + if (verifyIssues.length > 0) { + assertEq(verifyIssues[0].severity, 'warning', 'placeholder_verification severity is warning'); + assertEq(verifyIssues[0].scope, 'task-plan', 'placeholder_verification scope is task-plan'); + } +} + +console.log('\n=== validateTaskPlanContent: Verification with only template text ==='); +{ + const content = `# T01: Some Task + +## Steps + +1. Do the thing. + +## Verification + +{{whatWasVerifiedAndHow — commands run, tests passed, behavior confirmed}} +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const verifyIssues = issues.filter(i => i.ruleId === 'placeholder_verification'); + assert(verifyIssues.length >= 1, 'template-text-only Verification produces placeholder_verification issue'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateSlicePlanContent — empty inline task entries +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateSlicePlanContent: empty inline task entries ==='); +{ + const content = `# S01: Some Slice + +**Goal:** Build the thing. +**Demo:** It works. + +## Tasks + +- [ ] **T01: First Task** \`est:20m\` + +- [ ] **T02: Second Task** \`est:15m\` + +## Verification + +- Run the tests. +`; + + const issues = validateSlicePlanContent('S01-PLAN.md', content); + const emptyTaskIssues = issues.filter(i => i.ruleId === 'empty_task_entry'); + assert(emptyTaskIssues.length >= 1, 'task entries with no description produce empty_task_entry issue'); + if (emptyTaskIssues.length > 0) { + assertEq(emptyTaskIssues[0].severity, 'warning', 'empty_task_entry severity is warning'); + assertEq(emptyTaskIssues[0].scope, 'slice-plan', 'empty_task_entry scope is slice-plan'); + } +} + +console.log('\n=== validateSlicePlanContent: task entries with content are fine ==='); +{ + const content = `# S01: Some Slice + +**Goal:** Build the thing. +**Demo:** It works. + +## Tasks + +- [ ] **T01: First Task** \`est:20m\` + - Why: Because it matters. + - Files: \`src/index.ts\` + - Do: Implement the feature. + +- [ ] **T02: Second Task** \`est:15m\` + - Why: Also important. + - Do: Add tests. + +## Verification + +- Run the tests. +`; + + const issues = validateSlicePlanContent('S01-PLAN.md', content); + const emptyTaskIssues = issues.filter(i => i.ruleId === 'empty_task_entry'); + assertEq(emptyTaskIssues.length, 0, 'task entries with description content produce no empty_task_entry issues'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateTaskPlanContent — scope_estimate over threshold +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateTaskPlanContent: scope_estimate over threshold ==='); +{ + const content = `--- +estimated_steps: 12 +estimated_files: 15 +--- + +# T01: Big Task + +## Steps + +1. Step one. +2. Step two. +3. Step three. + +## Verification + +- Check it works. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const stepsOverIssues = issues.filter(i => i.ruleId === 'scope_estimate_steps_high'); + const filesOverIssues = issues.filter(i => i.ruleId === 'scope_estimate_files_high'); + assert(stepsOverIssues.length >= 1, 'estimated_steps=12 (>=10) produces scope_estimate_steps_high issue'); + assert(filesOverIssues.length >= 1, 'estimated_files=15 (>=12) produces scope_estimate_files_high issue'); + if (stepsOverIssues.length > 0) { + assertEq(stepsOverIssues[0].severity, 'warning', 'scope_estimate_steps_high severity is warning'); + assertEq(stepsOverIssues[0].scope, 'task-plan', 'scope_estimate_steps_high scope is task-plan'); + } + if (filesOverIssues.length > 0) { + assertEq(filesOverIssues[0].severity, 'warning', 'scope_estimate_files_high severity is warning'); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateTaskPlanContent — scope_estimate within limits +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateTaskPlanContent: scope_estimate within limits ==='); +{ + const content = `--- +estimated_steps: 4 +estimated_files: 6 +--- + +# T01: Small Task + +## Steps + +1. Do the thing. + +## Verification + +- Verify it works. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const scopeIssues = issues.filter(i => + i.ruleId === 'scope_estimate_steps_high' || i.ruleId === 'scope_estimate_files_high' + ); + assertEq(scopeIssues.length, 0, 'scope_estimate within limits produces no scope issues'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// validateTaskPlanContent — missing scope_estimate (no warning) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== validateTaskPlanContent: missing scope_estimate ==='); +{ + const content = `# T01: No Frontmatter Task + +## Steps + +1. Do the thing. + +## Verification + +- Verify it works. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const scopeIssues = issues.filter(i => + i.ruleId === 'scope_estimate_steps_high' || i.ruleId === 'scope_estimate_files_high' + ); + assertEq(scopeIssues.length, 0, 'missing scope_estimate produces no scope issues'); +} + +console.log('\n=== validateTaskPlanContent: frontmatter without scope keys ==='); +{ + const content = `--- +id: T01 +parent: S01 +--- + +# T01: Task With Other Frontmatter + +## Steps + +1. Do the thing. + +## Verification + +- Verify it works. +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const scopeIssues = issues.filter(i => + i.ruleId === 'scope_estimate_steps_high' || i.ruleId === 'scope_estimate_files_high' + ); + assertEq(scopeIssues.length, 0, 'frontmatter without scope keys produces no scope issues'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Clean plans — no false positives +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== Clean task plan: no plan-quality issues ==='); +{ + const content = `--- +estimated_steps: 5 +estimated_files: 3 +--- + +# T01: Well-Formed Task + +## Description + +A real task with real content. + +## Steps + +1. Read the input files. +2. Parse the configuration. +3. Transform the data. +4. Write the output. +5. Verify the results. + +## Must-Haves + +- [ ] Output file is valid JSON +- [ ] All input records are processed + +## Verification + +- Run \`node --test tests/transform.test.ts\` — all assertions pass +- Manually inspect output.json for correct structure + +## Observability Impact + +- Signals added/changed: structured error log on parse failure +- How a future agent inspects this: check stderr for JSON parse errors +- Failure state exposed: exit code 1 + error message on invalid input +`; + + const issues = validateTaskPlanContent('T01-PLAN.md', content); + const planQualityIssues = issues.filter(i => + i.ruleId === 'empty_steps_section' || + i.ruleId === 'placeholder_verification' || + i.ruleId === 'scope_estimate_steps_high' || + i.ruleId === 'scope_estimate_files_high' + ); + assertEq(planQualityIssues.length, 0, 'clean task plan produces no plan-quality issues'); +} + +console.log('\n=== Clean slice plan: no plan-quality issues ==='); +{ + const content = `# S01: Well-Formed Slice + +**Goal:** Build a complete feature. +**Demo:** Run the test suite and see all green. + +## Tasks + +- [ ] **T01: Create tests** \`est:20m\` + - Why: Tests define the contract before implementation. + - Files: \`tests/feature.test.ts\` + - Do: Write comprehensive test assertions. + - Verify: Test file runs without syntax errors. + +- [ ] **T02: Implement feature** \`est:30m\` + - Why: Core implementation. + - Files: \`src/feature.ts\` + - Do: Build the feature to make tests pass. + - Verify: All tests pass. + +## Verification + +- \`node --test tests/feature.test.ts\` — all assertions pass +- Check error output for diagnostic messages + +## Observability / Diagnostics + +- Runtime signals: structured error objects with error codes +- Inspection surfaces: test output shows pass/fail counts +- Failure visibility: exit code 1 on failure with descriptive message +- Redaction constraints: none +`; + + const issues = validateSlicePlanContent('S01-PLAN.md', content); + const planQualityIssues = issues.filter(i => i.ruleId === 'empty_task_entry'); + assertEq(planQualityIssues.length, 0, 'clean slice plan produces no empty_task_entry issues'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Results +// ═══════════════════════════════════════════════════════════════════════════ + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✓'); diff --git a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts new file mode 100644 index 000000000..7681aa5f2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts @@ -0,0 +1,171 @@ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// loadPrompt reads from ~/.pi/agent/extensions/gsd/prompts/ (main checkout). +// In a worktree the file may not exist there yet, so we resolve prompts +// relative to this test file's location (the worktree copy). +const __dirname = dirname(fileURLToPath(import.meta.url)); +const worktreePromptsDir = join(__dirname, "..", "prompts"); + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +/** + * Load a prompt template from the worktree prompts directory + * and apply variable substitution (mirrors loadPrompt logic). + */ +function loadPromptFromWorktree(name: string, vars: Record = {}): string { + const path = join(worktreePromptsDir, `${name}.md`); + let content = readFileSync(path, "utf-8"); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + return content.trim(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── reassess-roadmap prompt loads and substitutes ───────────────────── + console.log("\n=== reassess-roadmap prompt loads and substitutes ==="); + { + const testVars = { + milestoneId: "M099", + completedSliceId: "S03", + assessmentAbsPath: ".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md", + roadmapPath: ".gsd/milestones/M099/M099-ROADMAP.md", + inlinedContext: "--- test inlined context block ---", + }; + + let result: string; + let threw = false; + try { + result = loadPromptFromWorktree("reassess-roadmap", testVars); + } catch (err) { + threw = true; + result = ""; + console.error(` ERROR: loadPrompt threw: ${err}`); + } + + assert(!threw, "loadPrompt does not throw for reassess-roadmap"); + assert(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string"); + + // Verify all test variables were substituted into the output + assert(result.includes("M099"), "prompt contains milestoneId 'M099'"); + assert(result.includes("S03"), "prompt contains completedSliceId 'S03'"); + assert(result.includes(".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md"), "prompt contains assessmentAbsPath"); + assert(result.includes(".gsd/milestones/M099/M099-ROADMAP.md"), "prompt contains roadmapPath"); + assert(result.includes("--- test inlined context block ---"), "prompt contains inlinedContext"); + + // Verify no un-substituted variables remain + assert(!result.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}"); + assert(!result.includes("{{completedSliceId}}"), "no un-substituted {{completedSliceId}}"); + assert(!result.includes("{{assessmentAbsPath}}"), "no un-substituted {{assessmentAbsPath}}"); + assert(!result.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}"); + assert(!result.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}"); + } + + // ─── reassess-roadmap contains coverage-check instruction ───────────── + console.log("\n=== reassess-roadmap contains coverage-check instruction ==="); + { + const prompt = loadPromptFromWorktree("reassess-roadmap", { + milestoneId: "M001", + completedSliceId: "S01", + assessmentAbsPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedContext: "context", + }); + + // Normalize to lowercase for case-insensitive matching + const lower = prompt.toLowerCase(); + + // The prompt must mention "each success criterion" or "every success criterion" + assert( + lower.includes("each success criterion") || lower.includes("every success criterion"), + "prompt contains 'each success criterion' or 'every success criterion'" + ); + + // The prompt must mention "owning slice" or "remaining slice" + assert( + lower.includes("owning slice") || lower.includes("remaining slice"), + "prompt contains 'owning slice' or 'remaining slice'" + ); + + // The prompt must mention "no remaining owner" or "no owner" or "no slice" + assert( + lower.includes("no remaining owner") || lower.includes("no owner") || lower.includes("no slice"), + "prompt contains 'no remaining owner', 'no owner', or 'no slice'" + ); + + // The prompt must mention "blocking issue" or "blocking" + assert( + lower.includes("blocking issue") || lower.includes("blocking"), + "prompt contains 'blocking issue' or 'blocking'" + ); + } + + // ─── coverage-check requires at-least-one semantics ─────────────────── + console.log("\n=== coverage-check requires at-least-one semantics ==="); + { + const prompt = loadPromptFromWorktree("reassess-roadmap", { + milestoneId: "M001", + completedSliceId: "S01", + assessmentAbsPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedContext: "context", + }); + + const lower = prompt.toLowerCase(); + + // The instruction must use "at least one" or equivalent inclusive language + assert( + lower.includes("at least one") || lower.includes("at-least-one") || lower.includes("one or more"), + "prompt uses 'at least one' or equivalent inclusive language for slice ownership" + ); + + // The instruction must NOT require "exactly one" — that would be too rigid + assert( + !lower.includes("exactly one owner") && !lower.includes("exactly one slice"), + "prompt does NOT use 'exactly one' for slice ownership (would be too rigid)" + ); + } + + // ═════════════════════════════════════════════════════════════════════════ + // Results + // ═════════════════════════════════════════════════════════════════════════ + + console.log(`\n${"=".repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log("All tests passed ✓"); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts new file mode 100644 index 000000000..04b183667 --- /dev/null +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -0,0 +1,523 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { parseSummary } from '../files.ts'; +import { deriveState } from '../state.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const worktreePromptsDir = join(__dirname, '..', 'prompts'); + +/** + * Load a prompt template from the worktree prompts directory + * and apply variable substitution (mirrors loadPrompt logic). + */ +function loadPromptFromWorktree(name: string, vars: Record = {}): string { + const path = join(worktreePromptsDir, `${name}.md`); + let content = readFileSync(path, 'utf-8'); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + return content.trim(); +} + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-replan-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writePlan(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(join(dir, 'tasks'), { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), content); +} + +function writeTaskSummary(base: string, mid: string, sid: string, tid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${tid}-SUMMARY.md`), content); +} + +function writeReplanFile(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}-REPLAN.md`), content); +} + +/** Standard roadmap with one slice having no dependencies */ +const ROADMAP_ONE_SLICE = `# M001: Test Milestone + +**Vision:** Test vision. + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > After this: stuff works +`; + +/** Plan with T01 done, T02 not done */ +function makePlanT01DoneT02Pending(): string { + return `# S01: Test Slice + +**Goal:** Do things. +**Demo:** It works. + +## Tasks + +- [x] **T01: First task** \`est:15m\` + First task description. + +- [ ] **T02: Second task** \`est:15m\` + Second task description. +`; +} + +/** Plan with T01 and T02 done, T03 not done */ +function makePlanT01T02DoneT03Pending(): string { + return `# S01: Test Slice + +**Goal:** Do things. +**Demo:** It works. + +## Tasks + +- [x] **T01: First task** \`est:15m\` + First task description. + +- [x] **T02: Second task** \`est:15m\` + Second task description. + +- [ ] **T03: Third task** \`est:15m\` + Third task description. +`; +} + +/** Minimal task summary with blocker_discovered flag */ +function makeTaskSummary(tid: string, blockerDiscovered: boolean): string { + return `--- +id: ${tid} +parent: S01 +milestone: M001 +provides: [] +key_files: [] +key_decisions: [] +patterns_established: [] +observability_surfaces: [] +duration: 15min +verification_result: passed +completed_at: 2025-03-10T12:00:00Z +blocker_discovered: ${blockerDiscovered} +--- + +# ${tid}: Test Task + +**Did something.** + +## What Happened + +Work was done. +`; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Parser Extraction: blocker_discovered +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseSummary: blocker_discovered true (string) ==='); +{ + const content = `--- +id: T01 +parent: S03 +milestone: M002 +blocker_discovered: true +completed_at: 2025-03-10T12:00:00Z +--- + +# T01: Test Task + +**One-liner.** + +## What Happened + +Found a blocker. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (string) extracts as true'); +} + +console.log('\n=== parseSummary: blocker_discovered false (string) ==='); +{ + const content = `--- +id: T02 +parent: S03 +milestone: M002 +blocker_discovered: false +completed_at: 2025-03-10T12:00:00Z +--- + +# T02: Normal Task + +**One-liner.** + +## What Happened + +No blocker. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered: false extracts as false'); +} + +console.log('\n=== parseSummary: blocker_discovered missing (defaults to false) ==='); +{ + const content = `--- +id: T03 +parent: S03 +milestone: M002 +completed_at: 2025-03-10T12:00:00Z +--- + +# T03: No Blocker Field + +**One-liner.** + +## What Happened + +No blocker field at all. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered missing defaults to false'); +} + +console.log('\n=== parseSummary: blocker_discovered true (boolean from YAML) ==='); +{ + // YAML parsers may deliver `true` as a boolean rather than the string "true" + // We test this via a summary that has blocker_discovered: true with no quotes + // The YAML parser in parseFrontmatterMap may return boolean true directly + const content = `--- +id: T04 +parent: S03 +milestone: M002 +blocker_discovered: true +completed_at: 2025-03-10T12:00:00Z +--- + +# T04: Boolean True + +**One-liner.** + +## What Happened + +Blocker as boolean. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (YAML boolean) extracts as true'); +} + +console.log('\n=== parseSummary: blocker_discovered with full frontmatter ==='); +{ + const content = `--- +id: T05 +parent: S03 +milestone: M002 +provides: + - something +requires: [] +affects: [] +key_files: + - files.ts +key_decisions: [] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: 15min +verification_result: passed +completed_at: 2025-03-10T12:00:00Z +blocker_discovered: true +--- + +# T05: Full Frontmatter With Blocker + +**Found an architectural mismatch.** + +## What Happened + +The API doesn't support what we assumed. + +## Deviations + +Major deviation from plan. + +## Files Created/Modified + +- \`files.ts\` — attempted changes +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered true with full frontmatter'); + assertEq(s.frontmatter.id, 'T05', 'other fields still parse correctly alongside blocker_discovered'); + assertEq(s.frontmatter.duration, '15min', 'duration still parsed'); + assertEq(s.frontmatter.provides[0], 'something', 'provides still parsed'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// State Detection: replanning-slice phase +// ═══════════════════════════════════════════════════════════════════════════ + +// (a) blocker found + no REPLAN.md → replanning-slice +console.log('\n=== deriveState: blocker found, no REPLAN → replanning-slice ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + + const state = await deriveState(base); + assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when blocker found and no REPLAN.md'); + assert(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01'); + assert(state.nextAction.includes('blocker_discovered'), 'nextAction mentions blocker_discovered'); + assertEq(state.activeTask?.id, 'T02', 'activeTask is still T02 (the next incomplete task)'); + assert(state.blockers.length > 0, 'blockers array is non-empty'); + rmSync(base, { recursive: true, force: true }); +} + +// (b) blocker found + REPLAN.md exists → executing (loop protection) +console.log('\n=== deriveState: blocker found + REPLAN exists → executing (loop protection) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.'); + + const state = await deriveState(base); + assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); + assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + rmSync(base, { recursive: true, force: true }); +} + +// (c) no blocker → executing +console.log('\n=== deriveState: no blocker in completed tasks → executing ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + + const state = await deriveState(base); + assertEq(state.phase, 'executing', 'phase is executing when no blocker found'); + assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + rmSync(base, { recursive: true, force: true }); +} + +// (d) multiple completed tasks, one with blocker → replanning-slice +console.log('\n=== deriveState: multiple completed tasks, one blocker → replanning-slice ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01T02DoneT03Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + writeTaskSummary(base, 'M001', 'S01', 'T02', makeTaskSummary('T02', true)); + + const state = await deriveState(base); + assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when T02 has blocker'); + assert(state.nextAction.includes('T02'), 'nextAction mentions blocker task T02'); + assertEq(state.activeTask?.id, 'T03', 'activeTask is T03 (next incomplete)'); + rmSync(base, { recursive: true, force: true }); +} + +// (e) completed task with no summary file → executing (gracefully skipped) +console.log('\n=== deriveState: completed task with no summary file → executing ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + // No summary file written for T01 + + const state = await deriveState(base); + assertEq(state.phase, 'executing', 'phase is executing when completed task has no summary'); + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompt: replan-slice template loading and substitution +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== prompt: replan-slice template loads and substitutes variables ==='); +{ + const prompt = loadPromptFromWorktree('replan-slice', { + milestoneId: 'M001', + sliceId: 'S01', + sliceTitle: 'Test Slice', + slicePath: '.gsd/milestones/M001/slices/S01', + planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md', + blockerTaskId: 'T02', + inlinedContext: '## Inlined Context\n\nTest context here.', + }); + + assert(prompt.includes('M001'), 'prompt contains milestoneId'); + assert(prompt.includes('S01'), 'prompt contains sliceId'); + assert(prompt.includes('Test Slice'), 'prompt contains sliceTitle'); + assert(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath'); + assert(prompt.includes('T02'), 'prompt contains blockerTaskId'); + assert(prompt.includes('Test context here'), 'prompt contains inlined context'); +} + +console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instruction ==='); +{ + const prompt = loadPromptFromWorktree('replan-slice', { + milestoneId: 'M001', + sliceId: 'S01', + sliceTitle: 'Test Slice', + slicePath: '.gsd/milestones/M001/slices/S01', + planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md', + blockerTaskId: 'T01', + inlinedContext: '', + }); + + assert(prompt.includes('Do NOT renumber or remove completed tasks'), 'prompt contains preserve-completed-tasks instruction'); + assert(prompt.includes('[x]'), 'prompt mentions [x] checkmarks'); + assert(prompt.includes('replanAbsPath') || prompt.includes('REPLAN'), 'prompt references replan output path'); + assert(prompt.includes('blocker_discovered'), 'prompt mentions blocker_discovered'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Dispatch: diagnoseExpectedArtifact for replan-slice +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== dispatch: diagnoseExpectedArtifact returns REPLAN.md path ==='); +{ + // We can't import diagnoseExpectedArtifact directly (it's not exported), + // but we can verify the prompt template has the right structure and + // the state machine routes correctly. The diagnose function is integration-tested + // via the dispatch chain. We verify indirectly via state phase detection. + + // Verify state correctly routes to replanning-slice phase + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + + const state = await deriveState(base); + assertEq(state.phase, 'replanning-slice', 'dispatch: state routes to replanning-slice when blocker found'); + assert(state.activeSlice?.id === 'S01', 'dispatch: activeSlice is S01'); + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Display Functions: unitVerb, unitPhaseLabel, peekNext entries +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== display: replan-slice prompt template has correct unit header ==='); +{ + const prompt = loadPromptFromWorktree('replan-slice', { + milestoneId: 'M001', + sliceId: 'S01', + sliceTitle: 'Test Slice', + slicePath: '.gsd/milestones/M001/slices/S01', + planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md', + blockerTaskId: 'T01', + inlinedContext: '', + }); + + assert(prompt.includes('UNIT: Replan Slice'), 'prompt has Replan Slice unit header'); + assert(prompt.includes('Slice S01 replanned'), 'prompt has completion message'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Doctor: blocker_discovered_no_replan diagnostics +// ═══════════════════════════════════════════════════════════════════════════ + +import { runGSDDoctor } from '../doctor.ts'; + +// (a) blocker + no REPLAN.md → issue emitted +console.log('\n=== doctor: blocker + no REPLAN.md → blocker_discovered_no_replan issue ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + + const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); + const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); + assert(blockerIssues.length > 0, 'doctor emits blocker_discovered_no_replan when blocker + no REPLAN'); + assert(blockerIssues[0]?.message.includes('T01'), 'issue message mentions the blocker task T01'); + assertEq(blockerIssues[0]?.severity, 'warning', 'blocker_discovered_no_replan is warning severity'); + assertEq(blockerIssues[0]?.scope, 'slice', 'blocker_discovered_no_replan has slice scope'); + rmSync(base, { recursive: true, force: true }); +} + +// (b) blocker + REPLAN.md exists → no issue +console.log('\n=== doctor: blocker + REPLAN.md exists → no blocker_discovered_no_replan issue ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); + writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.'); + + const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); + const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); + assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when REPLAN.md exists'); + rmSync(base, { recursive: true, force: true }); +} + +// (c) no blocker → no issue +console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); + + const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); + const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); + assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when no blocker'); + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Results +// ═══════════════════════════════════════════════════════════════════════════ + +console.log(`\n${'='.repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} else { + console.log('All tests passed ✓'); +} diff --git a/src/resources/extensions/gsd/tests/requirements.test.ts b/src/resources/extensions/gsd/tests/requirements.test.ts new file mode 100644 index 000000000..50ed7114f --- /dev/null +++ b/src/resources/extensions/gsd/tests/requirements.test.ts @@ -0,0 +1,125 @@ +import { parseRequirementCounts } from "../files.ts"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { deriveState } from "../state.ts"; +import { runGSDDoctor } from "../doctor.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +console.log("\n=== requirement counts parser ==="); +{ + const counts = parseRequirementCounts(`# Requirements + +## Active + +### R001 — Foo +- Status: active + +### R002 — Bar +- Status: blocked + +## Validated + +### R010 — Baz +- Status: validated + +## Deferred + +### R020 — Qux +- Status: deferred + +## Out of Scope + +### R030 — No +- Status: out-of-scope +`); + assertEq(counts.active, 2, "counts active requirements by section"); + assertEq(counts.validated, 1, "counts validated requirements"); + assertEq(counts.deferred, 1, "counts deferred requirements"); + assertEq(counts.outOfScope, 1, "counts out of scope requirements"); + assertEq(counts.blocked, 1, "counts blocked statuses"); +} + +const base = mkdtempSync(join(tmpdir(), "gsd-requirements-test-")); +const gsd = join(base, ".gsd"); +const mDir = join(gsd, "milestones", "M001"); +const sDir = join(mDir, "slices", "S01"); +const tDir = join(sDir, "tasks"); +mkdirSync(tDir, { recursive: true }); +writeFileSync(join(gsd, "REQUIREMENTS.md"), `# Requirements + +## Active + +### R001 — Missing owner +- Class: core-capability +- Status: active +- Description: thing +- Why it matters: thing +- Source: user +- Primary owning slice: none yet +- Supporting slices: none +- Validation: unmapped +- Notes: none + +## Validated + +## Deferred + +## Out of Scope + +## Traceability +`, "utf-8"); +writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo + +## Slices +- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\` + > After this: demo works +`, "utf-8"); +writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice + +**Goal:** Demo +**Demo:** Demo + +## Must-Haves +- done + +## Tasks +- [ ] **T01: Implement thing** \`est:10m\` + Task is in progress. +`, "utf-8"); + +console.log("\n=== deriveState includes requirements counts ==="); +{ + const state = await deriveState(base); + assert(state.requirements !== undefined, "state includes requirements summary"); + assertEq(state.requirements?.active, 1, "state reports active requirement count"); +} + +console.log("\n=== doctor flags orphaned active requirement ==="); +{ + const report = await runGSDDoctor(base); + assert(report.issues.some(issue => issue.code === "active_requirement_missing_owner"), "doctor flags missing owner"); +} + +rmSync(base, { recursive: true, force: true }); +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log("All tests passed ✓"); diff --git a/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs b/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs new file mode 100644 index 000000000..f3e1cd668 --- /dev/null +++ b/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs @@ -0,0 +1,17 @@ +// ESM resolve hook: .js → .ts rewriting for test environments. +// Only rewrites relative imports from our own source files — not from node_modules. + +export function resolve(specifier, context, nextResolve) { + const parentURL = context.parentURL || ''; + const isFromNodeModules = parentURL.includes('/node_modules/'); + + if (specifier.endsWith('.js') && !specifier.startsWith('node:') && !isFromNodeModules) { + const tsSpecifier = specifier.replace(/\.js$/, '.ts'); + try { + return nextResolve(tsSpecifier, context); + } catch { + // fall through to default resolution + } + } + return nextResolve(specifier, context); +} diff --git a/src/resources/extensions/gsd/tests/resolve-ts.mjs b/src/resources/extensions/gsd/tests/resolve-ts.mjs new file mode 100644 index 000000000..5bafd5219 --- /dev/null +++ b/src/resources/extensions/gsd/tests/resolve-ts.mjs @@ -0,0 +1,11 @@ +// Custom ESM resolver: rewrites .js imports to .ts for node --test with TypeScript sources. +// Usage: node --import ./agent/extensions/gsd/tests/resolve-ts.mjs --test ... +// +// This is needed because pi extension source files use .js import specifiers +// (the pi runtime bundler convention), but only .ts files exist on disk. +// Node's built-in TypeScript support strips types but doesn't rewrite specifiers. + +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +register(new URL('./resolve-ts-hooks.mjs', import.meta.url), pathToFileURL('./')); diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts new file mode 100644 index 000000000..1a06b8c01 --- /dev/null +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -0,0 +1,348 @@ +// Tests for extractUatType — the core UAT classification primitive — plus +// prompt template loading and dispatch precondition assertions (via +// resolveSliceFile / extractUatType on real fixture files). +// +// Sections: +// (a)–(j) extractUatType classification (17 assertions from T01) +// (k) run-uat prompt template loading and content integrity (8 assertions) +// (l) dispatch precondition assertions via resolveSliceFile (4 assertions) + +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { extractUatType } from '../files.ts'; +import { resolveSliceFile } from '../paths.ts'; + +// ─── Worktree-aware prompt loader ────────────────────────────────────────── +// Resolves prompts relative to this test file so the worktree copy is used +// instead of the main checkout copy (matches complete-milestone.test.ts pattern). + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const worktreePromptsDir = join(__dirname, '..', 'prompts'); + +function loadPromptFromWorktree(name: string, vars: Record = {}): string { + const path = join(worktreePromptsDir, `${name}.md`); + let content = readFileSync(path, 'utf-8'); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(`{{${key}}}`, value); + } + return content.trim(); +} + +// ─── Assertion helpers ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-run-uat-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeSliceFile( + base: string, + mid: string, + sid: string, + suffix: string, + content: string, +): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}-${suffix}.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +function makeUatContent(mode: string): string { + return `# UAT File\n\n## UAT Type\n\n- UAT mode: ${mode}\n- Some other bullet: value\n`; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── (a) artifact-driven ────────────────────────────────────────────────── + console.log('\n── (a) artifact-driven'); + + assertEq( + extractUatType(makeUatContent('artifact-driven')), + 'artifact-driven', + 'plain artifact-driven → artifact-driven', + ); + + assertEq( + extractUatType('## UAT Type\n\n- UAT mode: artifact-driven\n'), + 'artifact-driven', + 'minimal content, artifact-driven', + ); + + // ─── (b) live-runtime ───────────────────────────────────────────────────── + console.log('\n── (b) live-runtime'); + + assertEq( + extractUatType(makeUatContent('live-runtime')), + 'live-runtime', + 'plain live-runtime → live-runtime', + ); + + // ─── (c) human-experience ───────────────────────────────────────────────── + console.log('\n── (c) human-experience'); + + assertEq( + extractUatType(makeUatContent('human-experience')), + 'human-experience', + 'plain human-experience → human-experience', + ); + + // ─── (d) mixed standalone ───────────────────────────────────────────────── + console.log('\n── (d) mixed standalone'); + + assertEq( + extractUatType(makeUatContent('mixed')), + 'mixed', + 'plain mixed → mixed', + ); + + // ─── (e) mixed with parenthetical ───────────────────────────────────────── + console.log('\n── (e) mixed parenthetical'); + + assertEq( + extractUatType(makeUatContent('mixed (artifact-driven + live-runtime)')), + 'mixed', + 'mixed (artifact-driven + live-runtime) → mixed (leading keyword only)', + ); + + assertEq( + extractUatType(makeUatContent('mixed (some other description)')), + 'mixed', + 'mixed with arbitrary parenthetical → mixed', + ); + + // ─── (f) missing ## UAT Type section ────────────────────────────────────── + console.log('\n── (f) missing UAT Type section'); + + assertEq( + extractUatType('# UAT File\n\n## Overview\n\nSome content.\n'), + undefined, + 'no ## UAT Type section → undefined', + ); + + assertEq( + extractUatType(''), + undefined, + 'empty content → undefined', + ); + + // ─── (g) ## UAT Type present but no UAT mode: bullet ───────────────────── + console.log('\n── (g) UAT Type section present, no UAT mode: bullet'); + + assertEq( + extractUatType('## UAT Type\n\n- Some other bullet: value\n- Another bullet\n'), + undefined, + 'section present but no UAT mode: bullet → undefined', + ); + + assertEq( + extractUatType('## UAT Type\n\n'), + undefined, + 'section present but empty → undefined', + ); + + // ─── (h) unknown keyword ────────────────────────────────────────────────── + console.log('\n── (h) unknown keyword'); + + assertEq( + extractUatType(makeUatContent('automated')), + undefined, + 'unknown keyword automated → undefined', + ); + + assertEq( + extractUatType(makeUatContent('fully-automated')), + undefined, + 'unknown keyword fully-automated → undefined', + ); + + // ─── (i) extra whitespace around value ──────────────────────────────────── + console.log('\n── (i) extra whitespace'); + + assertEq( + extractUatType('## UAT Type\n\n- UAT mode: artifact-driven \n'), + 'artifact-driven', + 'leading/trailing whitespace around value → still classified correctly', + ); + + assertEq( + extractUatType('## UAT Type\n\n- UAT mode: mixed (artifact-driven + live-runtime) \n'), + 'mixed', + 'whitespace around mixed parenthetical → mixed', + ); + + // ─── (j) case sensitivity ───────────────────────────────────────────────── + console.log('\n── (j) case sensitivity'); + + assertEq( + extractUatType(makeUatContent('Artifact-Driven')), + 'artifact-driven', + 'Artifact-Driven (title case) → artifact-driven (function lowercases before matching)', + ); + + assertEq( + extractUatType(makeUatContent('MIXED')), + 'mixed', + 'MIXED (upper case) → mixed (function lowercases before matching)', + ); + + // ─── (k) prompt template loading and content integrity ──────────────────── + console.log('\n── (k) run-uat prompt template'); + + const milestoneId = 'M001'; + const sliceId = 'S01'; + const uatPath = '.gsd/milestones/M001/slices/S01/S01-UAT.md'; + const uatResultAbsPath = '/tmp/gsd-test/S01-UAT-RESULT.md'; + const uatResultPath = '.gsd/milestones/M001/slices/S01/S01-UAT-RESULT.md'; + const uatType = 'artifact-driven'; + const inlinedContext = ''; + + let promptResult: string | undefined; + let promptThrew = false; + try { + promptResult = loadPromptFromWorktree('run-uat', { + milestoneId, + sliceId, + uatPath, + uatResultAbsPath, + uatResultPath, + uatType, + inlinedContext, + }); + } catch { + promptThrew = true; + } + + assert(!promptThrew, 'loadPromptFromWorktree("run-uat", vars) does not throw'); + assert( + typeof promptResult === 'string' && promptResult.length > 0, + 'run-uat prompt result is a non-empty string', + ); + assert( + promptResult?.includes(milestoneId) ?? false, + `prompt contains milestoneId value "${milestoneId}" after substitution`, + ); + assert( + promptResult?.includes(sliceId) ?? false, + `prompt contains sliceId value "${sliceId}" after substitution`, + ); + assert( + promptResult?.includes(uatResultAbsPath) ?? false, + `prompt contains uatResultAbsPath value after substitution`, + ); + assert( + !/\{\{[^}]+\}\}/.test(promptResult ?? ''), + 'no unreplaced {{...}} tokens remain after variable substitution', + ); + assert( + /artifact|execute|run/i.test(promptResult ?? ''), + 'prompt contains artifact-driven execution language (artifact/execute/run)', + ); + assert( + /surfaced for human review/i.test(promptResult ?? ''), + 'prompt contains "surfaced for human review" text for non-artifact-driven path', + ); + + // ─── (l) dispatch precondition assertions via resolveSliceFile ──────────── + console.log('\n── (l) dispatch preconditions via resolveSliceFile'); + + // State A: UAT file exists, UAT-RESULT file does NOT — triggers dispatch + { + const base = createFixtureBase(); + const uatContent = makeUatContent('artifact-driven'); + try { + writeSliceFile(base, 'M001', 'S01', 'UAT', uatContent); + + const uatFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT'); + assert( + uatFilePath !== null, + 'resolveSliceFile(..., "UAT") returns non-null when UAT file exists (dispatch trigger state)', + ); + + const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT'); + assertEq( + uatResultFilePath, + null, + 'resolveSliceFile(..., "UAT-RESULT") returns null when result file missing (dispatch trigger state)', + ); + + // End-to-end: file content → parse → classify + const rawContent = readFileSync(uatFilePath!, 'utf-8'); + assertEq( + extractUatType(rawContent), + 'artifact-driven', + 'extractUatType on fixture UAT file returns expected type (end-to-end data flow)', + ); + } finally { + cleanup(base); + } + } + + // State B: UAT-RESULT file exists — dispatch is skipped (idempotent) + { + const base = createFixtureBase(); + try { + writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven')); + writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '# UAT Result\n\nverdict: PASS\n'); + + const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT'); + assert( + uatResultFilePath !== null, + 'resolveSliceFile(..., "UAT-RESULT") returns non-null when result file exists (idempotent skip state)', + ); + } finally { + cleanup(base); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Results + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + if (failed > 0) { + process.exit(1); + } else { + console.log('All tests passed ✓'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/unit-runtime.test.ts b/src/resources/extensions/gsd/tests/unit-runtime.test.ts new file mode 100644 index 000000000..219190c48 --- /dev/null +++ b/src/resources/extensions/gsd/tests/unit-runtime.test.ts @@ -0,0 +1,247 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + clearUnitRuntimeRecord, + formatExecuteTaskRecoveryStatus, + inspectExecuteTaskDurability, + readUnitRuntimeRecord, + writeUnitRuntimeRecord, +} from "../unit-runtime.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +const base = mkdtempSync(join(tmpdir(), "gsd-unit-runtime-test-")); +const tasksDir = join(base, ".gsd", "milestones", "M100", "slices", "S02", "tasks"); +mkdirSync(tasksDir, { recursive: true }); +writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T09 for S02: do the thing\n", "utf-8"); +writeFileSync( + join(base, ".gsd", "milestones", "M100", "slices", "S02", "S02-PLAN.md"), + "# S02: Test Slice\n\n## Tasks\n\n- [ ] **T09: Do the thing** `est:10m`\n Description.\n", + "utf-8", +); + +console.log("\n=== runtime record write/read/update ==="); +{ + const first = writeUnitRuntimeRecord(base, "execute-task", "M100/S02/T09", 1000, { phase: "dispatched" }); + assertEq(first.phase, "dispatched", "initial phase"); + const second = writeUnitRuntimeRecord(base, "execute-task", "M100/S02/T09", 1000, { phase: "wrapup-warning-sent", wrapupWarningSent: true }); + assertEq(second.wrapupWarningSent, true, "warning persisted"); + const loaded = readUnitRuntimeRecord(base, "execute-task", "M100/S02/T09"); + assert(loaded !== null, "record readable"); + assertEq(loaded!.phase, "wrapup-warning-sent", "updated phase readable"); +} + +console.log("\n=== execute-task durability inspection ==="); +{ + let status = await inspectExecuteTaskDurability(base, "M100/S02/T09"); + assert(status !== null, "status exists"); + assertEq(status!.summaryExists, false, "summary initially missing"); + assertEq(status!.taskChecked, false, "task initially unchecked"); + assertEq(status!.nextActionAdvanced, false, "next action initially stale"); + assert(/summary missing/i.test(formatExecuteTaskRecoveryStatus(status!)), "diagnostic mentions summary"); + + writeFileSync(join(tasksDir, "T09-SUMMARY.md"), "# done\n", "utf-8"); + writeFileSync( + join(base, ".gsd", "milestones", "M100", "slices", "S02", "S02-PLAN.md"), + "# S02: Test Slice\n\n## Tasks\n\n- [x] **T09: Do the thing** `est:10m`\n Description.\n", + "utf-8", + ); + writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T10 for S02: next thing\n", "utf-8"); + + status = await inspectExecuteTaskDurability(base, "M100/S02/T09"); + assertEq(status!.summaryExists, true, "summary found after write"); + assertEq(status!.taskChecked, true, "task checked after update"); + assertEq(status!.nextActionAdvanced, true, "next action advanced after update"); + assertEq(formatExecuteTaskRecoveryStatus(status!), "all durable task artifacts present", "clean diagnostic when complete"); +} + +console.log("\n=== runtime record cleanup ==="); +{ + clearUnitRuntimeRecord(base, "execute-task", "M100/S02/T09"); + const loaded = readUnitRuntimeRecord(base, "execute-task", "M100/S02/T09"); + assertEq(loaded, null, "record removed"); +} + +// ─── Must-have durability integration tests ─────────────────────────────── + +// Create a separate temp base for must-have tests to avoid interference +const mhBase = mkdtempSync(join(tmpdir(), "gsd-unit-runtime-mh-test-")); + +console.log("\n=== must-haves: all mentioned in summary ==="); +{ + const tasksDir2 = join(mhBase, ".gsd", "milestones", "M200", "slices", "S01", "tasks"); + mkdirSync(tasksDir2, { recursive: true }); + + // Slice plan with T01 checked + writeFileSync( + join(mhBase, ".gsd", "milestones", "M200", "slices", "S01", "S01-PLAN.md"), + "# S01: Test\n\n## Tasks\n\n- [x] **T01: Build parser** `est:10m`\n Build the parser.\n", + "utf-8", + ); + // Task plan with must-haves containing backtick code tokens + writeFileSync( + join(tasksDir2, "T01-PLAN.md"), + "# T01: Build parser\n\n## Must-Haves\n\n- [ ] `parseWidget` function is exported\n- [ ] `formatWidget` handles edge cases\n- [ ] All existing tests pass\n\n## Steps\n\n1. Do stuff\n", + "utf-8", + ); + // Summary that mentions all must-haves + writeFileSync( + join(tasksDir2, "T01-SUMMARY.md"), + "# T01: Build parser\n\nAdded parseWidget function and formatWidget with edge case handling. All existing tests pass without regression.\n", + "utf-8", + ); + // STATE.md with next action advanced past T01 + writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S01: next thing\n", "utf-8"); + + const status = await inspectExecuteTaskDurability(mhBase, "M200/S01/T01"); + assert(status !== null, "mh-all: status exists"); + assertEq(status!.mustHaveCount, 3, "mh-all: mustHaveCount is 3"); + assertEq(status!.mustHavesMentionedInSummary, 3, "mh-all: all 3 must-haves mentioned"); + assertEq(status!.summaryExists, true, "mh-all: summary exists"); + assertEq(status!.taskChecked, true, "mh-all: task checked"); + const diag = formatExecuteTaskRecoveryStatus(status!); + assertEq(diag, "all durable task artifacts present", "mh-all: diagnostic is clean when all must-haves met"); +} + +console.log("\n=== must-haves: partially mentioned in summary ==="); +{ + const tasksDir3 = join(mhBase, ".gsd", "milestones", "M200", "slices", "S02", "tasks"); + mkdirSync(tasksDir3, { recursive: true }); + + writeFileSync( + join(mhBase, ".gsd", "milestones", "M200", "slices", "S02", "S02-PLAN.md"), + "# S02: Test\n\n## Tasks\n\n- [x] **T01: Build thing** `est:10m`\n Build.\n", + "utf-8", + ); + // Task plan with 3 must-haves, summary will only mention 1 + writeFileSync( + join(tasksDir3, "T01-PLAN.md"), + "# T01: Build thing\n\n## Must-Haves\n\n- [ ] `computeScore` function is exported\n- [ ] `validateInput` rejects invalid data\n- [ ] `renderOutput` handles empty arrays\n\n## Steps\n\n1. Do stuff\n", + "utf-8", + ); + // Summary only mentions computeScore + writeFileSync( + join(tasksDir3, "T01-SUMMARY.md"), + "# T01: Build thing\n\nAdded computeScore function with full test coverage.\n", + "utf-8", + ); + writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S02: next thing\n", "utf-8"); + + const status = await inspectExecuteTaskDurability(mhBase, "M200/S02/T01"); + assert(status !== null, "mh-partial: status exists"); + assertEq(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3"); + assertEq(status!.mustHavesMentionedInSummary, 1, "mh-partial: only 1 must-have mentioned"); + const diag = formatExecuteTaskRecoveryStatus(status!); + assert(diag.includes("must-have gap"), "mh-partial: diagnostic includes 'must-have gap'"); + assert(diag.includes("1 of 3"), "mh-partial: diagnostic includes '1 of 3'"); +} + +console.log("\n=== must-haves: no task plan file ==="); +{ + const tasksDir4 = join(mhBase, ".gsd", "milestones", "M200", "slices", "S03", "tasks"); + mkdirSync(tasksDir4, { recursive: true }); + + writeFileSync( + join(mhBase, ".gsd", "milestones", "M200", "slices", "S03", "S03-PLAN.md"), + "# S03: Test\n\n## Tasks\n\n- [x] **T01: Quick fix** `est:5m`\n Fix.\n", + "utf-8", + ); + // No T01-PLAN.md — only summary + writeFileSync( + join(tasksDir4, "T01-SUMMARY.md"), + "# T01: Quick fix\n\nFixed the thing.\n", + "utf-8", + ); + writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S03: next thing\n", "utf-8"); + + const status = await inspectExecuteTaskDurability(mhBase, "M200/S03/T01"); + assert(status !== null, "mh-noplan: status exists"); + assertEq(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan"); + assertEq(status!.mustHavesMentionedInSummary, 0, "mh-noplan: mustHavesMentionedInSummary is 0"); +} + +console.log("\n=== must-haves: present but no summary file ==="); +{ + const tasksDir5 = join(mhBase, ".gsd", "milestones", "M200", "slices", "S04", "tasks"); + mkdirSync(tasksDir5, { recursive: true }); + + writeFileSync( + join(mhBase, ".gsd", "milestones", "M200", "slices", "S04", "S04-PLAN.md"), + "# S04: Test\n\n## Tasks\n\n- [ ] **T01: Build parser** `est:10m`\n Build.\n", + "utf-8", + ); + // Task plan with must-haves but NO summary file + writeFileSync( + join(tasksDir5, "T01-PLAN.md"), + "# T01: Build parser\n\n## Must-Haves\n\n- [ ] `parseData` function exported\n- [ ] Error handling covers edge cases\n\n## Steps\n\n1. Do stuff\n", + "utf-8", + ); + writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S04: build parser\n", "utf-8"); + + const status = await inspectExecuteTaskDurability(mhBase, "M200/S04/T01"); + assert(status !== null, "mh-nosummary: status exists"); + assertEq(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2"); + assertEq(status!.mustHavesMentionedInSummary, 0, "mh-nosummary: mustHavesMentionedInSummary is 0 with no summary"); + assertEq(status!.summaryExists, false, "mh-nosummary: summary doesn't exist"); +} + +console.log("\n=== must-haves: substring matching (no backtick tokens) ==="); +{ + const tasksDir6 = join(mhBase, ".gsd", "milestones", "M200", "slices", "S05", "tasks"); + mkdirSync(tasksDir6, { recursive: true }); + + writeFileSync( + join(mhBase, ".gsd", "milestones", "M200", "slices", "S05", "S05-PLAN.md"), + "# S05: Test\n\n## Tasks\n\n- [x] **T01: Add diagnostics** `est:10m`\n Add.\n", + "utf-8", + ); + // Must-haves with no backtick tokens — falls back to substring matching + writeFileSync( + join(tasksDir6, "T01-PLAN.md"), + "# T01: Add diagnostics\n\n## Must-Haves\n\n- [ ] Heuristic matching prioritizes backtick-enclosed code tokens\n- [ ] Recovery diagnostic string shows gap count\n- [ ] All assertions pass\n\n## Steps\n\n1. Do stuff\n", + "utf-8", + ); + // Summary mentions "heuristic" and "diagnostic" but not "assertions" + writeFileSync( + join(tasksDir6, "T01-SUMMARY.md"), + "# T01: Add diagnostics\n\nImplemented heuristic matching for must-have items. Recovery diagnostic string now includes gap counts.\n", + "utf-8", + ); + writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S05: next thing\n", "utf-8"); + + const status = await inspectExecuteTaskDurability(mhBase, "M200/S05/T01"); + assert(status !== null, "mh-substr: status exists"); + assertEq(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3"); + // "heuristic" appears in summary for item 1, "diagnostic" for item 2, + // "assertions" appears in summary? No — let's check + // Item 3: "All assertions pass" — words: "assertions", "pass" (<4 chars excluded) + // summary doesn't contain "assertions" → not matched + assertEq(status!.mustHavesMentionedInSummary, 2, "mh-substr: 2 of 3 matched via substring"); + const diag = formatExecuteTaskRecoveryStatus(status!); + assert(diag.includes("must-have gap"), "mh-substr: diagnostic includes gap info"); + assert(diag.includes("2 of 3"), "mh-substr: diagnostic includes '2 of 3'"); +} + +rmSync(mhBase, { recursive: true, force: true }); +rmSync(base, { recursive: true, force: true }); +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log("All tests passed ✓"); diff --git a/src/resources/extensions/gsd/tests/workspace-index.test.ts b/src/resources/extensions/gsd/tests/workspace-index.test.ts new file mode 100644 index 000000000..02b460a04 --- /dev/null +++ b/src/resources/extensions/gsd/tests/workspace-index.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { getSuggestedNextCommands, indexWorkspace, listDoctorScopeSuggestions } from "../workspace-index.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +const base = mkdtempSync(join(tmpdir(), "gsd-workspace-index-test-")); +const gsd = join(base, ".gsd"); +const mDir = join(gsd, "milestones", "M001"); +const sDir = join(mDir, "slices", "S01"); +const tDir = join(sDir, "tasks"); +mkdirSync(tDir, { recursive: true }); + +writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo Milestone + +## Slices +- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\` + > After this: demo works +`); + +writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice + +**Goal:** Demo +**Demo:** Demo + +## Must-Haves +- done + +## Tasks +- [ ] **T01: Implement thing** \`est:10m\` + Task is in progress. +`); + +writeFileSync(join(tDir, "T01-PLAN.md"), `# T01: Implement thing + +## Steps +- do it +`); + +async function main(): Promise { + console.log("\n=== workspace index ==="); + { + const index = await indexWorkspace(base); + assertEq(index.active.milestoneId, "M001", "active milestone indexed"); + assertEq(index.active.sliceId, "S01", "active slice indexed"); + assertEq(index.active.taskId, "T01", "active task indexed"); + assert(index.scopes.some(scope => scope.scope === "M001/S01"), "slice scope listed"); + assert(index.scopes.some(scope => scope.scope === "M001/S01/T01"), "task scope listed"); + } + + console.log("\n=== doctor scope suggestions ==="); + { + const suggestions = await listDoctorScopeSuggestions(base); + assertEq(suggestions[0].value, "M001/S01", "active slice suggested first"); + assert(suggestions.some(item => item.value === "M001/S01/T01"), "task scope suggested"); + } + + console.log("\n=== next command suggestions ==="); + { + const commands = await getSuggestedNextCommands(base); + assert(commands.includes("/gsd auto"), "suggests auto during execution"); + assert(commands.includes("/gsd doctor M001/S01"), "suggests scoped doctor"); + assert(commands.includes("/gsd status"), "suggests status"); + } + + rmSync(base, { recursive: true, force: true }); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + console.log("All tests passed ✓"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts new file mode 100644 index 000000000..5e9c0c1cf --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { + autoCommitCurrentBranch, + ensureSliceBranch, + getActiveSliceBranch, + getCurrentBranch, + getSliceBranchName, + mergeSliceToMain, + switchToMain, +} from "../worktree.ts"; +import { deriveState } from "../state.ts"; +import { indexWorkspace } from "../workspace-index.ts"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) passed++; + else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +const base = mkdtempSync(join(tmpdir(), "gsd-branch-test-")); +run("git init -b main", base); +run("git config user.name 'Pi Test'", base); +run("git config user.email 'pi@example.com'", base); +mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); +writeFileSync(join(base, "README.md"), "hello\n", "utf-8"); +writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), `# M001: Demo\n\n## Slices\n- [ ] **S01: Slice One** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`, "utf-8"); +writeFileSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `# S01: Slice One\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n do it\n`, "utf-8"); +run("git add .", base); +run("git commit -m 'chore: init'", base); + +async function main(): Promise { + console.log("\n=== ensureSliceBranch ==="); + const created = ensureSliceBranch(base, "M001", "S01"); + assert(created, "branch created on first ensure"); + assertEq(getCurrentBranch(base), "gsd/M001/S01", "switched to slice branch"); + + console.log("\n=== idempotent ensure ==="); + const secondCreate = ensureSliceBranch(base, "M001", "S01"); + assertEq(secondCreate, false, "branch not recreated on second ensure"); + assertEq(getCurrentBranch(base), "gsd/M001/S01", "still on slice branch"); + + console.log("\n=== getActiveSliceBranch ==="); + assertEq(getActiveSliceBranch(base), "gsd/M001/S01", "getActiveSliceBranch returns current slice branch"); + + console.log("\n=== state surfaces active branch ==="); + const state = await deriveState(base); + assertEq(state.activeBranch, "gsd/M001/S01", "state exposes active branch"); + + console.log("\n=== workspace index surfaces branch ==="); + const index = await indexWorkspace(base); + const slice = index.milestones[0]?.slices[0]; + assertEq(slice?.branch, "gsd/M001/S01", "workspace index exposes branch"); + + console.log("\n=== autoCommitCurrentBranch ==="); + // Clean — should return null + const cleanResult = autoCommitCurrentBranch(base, "execute-task", "M001/S01/T01"); + assertEq(cleanResult, null, "returns null for clean repo"); + + // Make dirty + writeFileSync(join(base, "dirty.txt"), "uncommitted\n", "utf-8"); + const dirtyResult = autoCommitCurrentBranch(base, "execute-task", "M001/S01/T01"); + assert(dirtyResult !== null, "returns commit message for dirty repo"); + assert(dirtyResult!.includes("M001/S01/T01"), "commit message includes unit id"); + assertEq(run("git status --short", base), "", "repo is clean after auto-commit"); + + console.log("\n=== switchToMain ==="); + switchToMain(base); + assertEq(getCurrentBranch(base), "main", "switched back to main"); + assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main"); + + console.log("\n=== mergeSliceToMain ==="); + // Switch back to slice, make a change, switch to main, merge + ensureSliceBranch(base, "M001", "S01"); + writeFileSync(join(base, "README.md"), "hello from slice\n", "utf-8"); + run("git add README.md", base); + run("git commit -m 'feat: slice change'", base); + switchToMain(base); + + const merge = mergeSliceToMain(base, "M001", "S01", "Slice One"); + assertEq(merge.branch, "gsd/M001/S01", "merge reports branch"); + assertEq(getCurrentBranch(base), "main", "still on main after merge"); + assert(readFileSync(join(base, "README.md"), "utf-8").includes("slice"), "main got squashed content"); + assert(merge.deletedBranch, "branch was deleted"); + + // Verify branch is actually gone + const branches = run("git branch", base); + assert(!branches.includes("gsd/M001/S01"), "slice branch no longer exists"); + + console.log("\n=== switchToMain auto-commits dirty files ==="); + // Set up S02 + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: Demo", "", "## Slices", + "- [x] **S01: Slice One** `risk:low` `depends:[]`", " > Done", + "- [ ] **S02: Slice Two** `risk:low` `depends:[]`", " > Demo 2", + ].join("\n") + "\n", "utf-8"); + run("git add .", base); + run("git commit -m 'chore: add S02'", base); + + ensureSliceBranch(base, "M001", "S02"); + writeFileSync(join(base, "feature.txt"), "new feature\n", "utf-8"); + // Don't commit — switchToMain should auto-commit + switchToMain(base); + assertEq(getCurrentBranch(base), "main", "switched to main despite dirty files"); + + // Verify the commit happened on the slice branch + ensureSliceBranch(base, "M001", "S02"); + assert(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "dirty file was committed on slice branch"); + switchToMain(base); + + // Now merge S02 + const mergeS02 = mergeSliceToMain(base, "M001", "S02", "Slice Two"); + assert(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "main got feature from auto-committed branch"); + assertEq(mergeS02.deletedBranch, true, "S02 branch deleted"); + + console.log("\n=== getSliceBranchName ==="); + assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct"); + + rmSync(base, { recursive: true, force: true }); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); + console.log("All tests passed ✓"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts new file mode 100644 index 000000000..4e89b7c88 --- /dev/null +++ b/src/resources/extensions/gsd/types.ts @@ -0,0 +1,159 @@ +// GSD Extension — Core Type Definitions +// Types consumed by state derivation, file parsing, and status display. +// Pure interfaces — no logic, no runtime dependencies. + +// ─── Enums & Literal Unions ──────────────────────────────────────────────── + +export type RiskLevel = 'low' | 'medium' | 'high'; +export type Phase = 'pre-planning' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked'; +export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted'; + +// ─── Roadmap (Milestone-level) ───────────────────────────────────────────── + +export interface RoadmapSliceEntry { + id: string; // e.g. "S01" + title: string; // e.g. "Types + File I/O + Git Operations" + risk: RiskLevel; + depends: string[]; // e.g. ["S01", "S02"] + done: boolean; + demo: string; // the "After this:" sentence +} + +export interface BoundaryMapEntry { + fromSlice: string; // e.g. "S01" + toSlice: string; // e.g. "S02" or "terminal" + produces: string; // raw text block of what this slice produces + consumes: string; // raw text block of what it consumes (or "nothing") +} + +export interface Roadmap { + title: string; // e.g. "M001: GSD Extension — Hierarchical Planning with Auto Mode" + vision: string; + successCriteria: string[]; + slices: RoadmapSliceEntry[]; + boundaryMap: BoundaryMapEntry[]; +} + +// ─── Slice Plan ──────────────────────────────────────────────────────────── + +export interface TaskPlanEntry { + id: string; // e.g. "T01" + title: string; // e.g. "Core Type Definitions" + description: string; + done: boolean; + estimate: string; // e.g. "30m", "2h" — informational only + files?: string[]; // e.g. ["types.ts", "files.ts"] — extracted from "- Files:" subline + verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline +} + +export interface SlicePlan { + id: string; // e.g. "S01" + title: string; // from the H1 + goal: string; + demo: string; + mustHaves: string[]; // top-level must-have bullet points + tasks: TaskPlanEntry[]; + filesLikelyTouched: string[]; +} + +// ─── Summary (Task & Slice level) ────────────────────────────────────────── + +export interface SummaryRequires { + slice: string; + provides: string; +} + +export interface SummaryFrontmatter { + id: string; + parent: string; + milestone: string; + provides: string[]; + requires: SummaryRequires[]; + affects: string[]; + key_files: string[]; + key_decisions: string[]; + patterns_established: string[]; + drill_down_paths: string[]; + observability_surfaces: string[]; + duration: string; + verification_result: string; + completed_at: string; + blocker_discovered: boolean; +} + +export interface FileModified { + path: string; + description: string; +} + +export interface Summary { + frontmatter: SummaryFrontmatter; + title: string; + oneLiner: string; + whatHappened: string; + deviations: string; + filesModified: FileModified[]; +} + +// ─── Continue-Here ───────────────────────────────────────────────────────── + +export interface ContinueFrontmatter { + milestone: string; + slice: string; + task: string; + step: number; + totalSteps: number; + status: ContinueStatus; + savedAt: string; +} + +export interface Continue { + frontmatter: ContinueFrontmatter; + completedWork: string; + remainingWork: string; + decisions: string; + context: string; + nextAction: string; +} + +// ─── GSD State (Derived Dashboard) ──────────────────────────────────────── + +export interface ActiveRef { + id: string; + title: string; +} + +export interface MilestoneRegistryEntry { + id: string; + title: string; + status: 'complete' | 'active' | 'pending'; + /** Milestone IDs that must be complete before this milestone becomes active. Populated from CONTEXT.md YAML frontmatter. */ + dependsOn?: string[]; +} + +export interface RequirementCounts { + active: number; + validated: number; + deferred: number; + outOfScope: number; + blocked: number; + total: number; +} + +export interface GSDState { + activeMilestone: ActiveRef | null; + activeSlice: ActiveRef | null; + activeTask: ActiveRef | null; + phase: Phase; + recentDecisions: string[]; + blockers: string[]; + nextAction: string; + activeBranch?: string; + registry: MilestoneRegistryEntry[]; + requirements?: RequirementCounts; + progress?: { + milestones: { done: number; total: number }; + slices?: { done: number; total: number }; + tasks?: { done: number; total: number }; + }; +} diff --git a/src/resources/extensions/gsd/unit-runtime.ts b/src/resources/extensions/gsd/unit-runtime.ts new file mode 100644 index 000000000..970f1c956 --- /dev/null +++ b/src/resources/extensions/gsd/unit-runtime.ts @@ -0,0 +1,162 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { + gsdRoot, + relSliceFile, + relTaskFile, + resolveSliceFile, + resolveTaskFile, +} from "./paths.ts"; +import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.ts"; + +export type UnitRuntimePhase = + | "dispatched" + | "wrapup-warning-sent" + | "timeout" + | "recovered" + | "finalized" + | "paused"; + +export interface ExecuteTaskRecoveryStatus { + planPath: string; + summaryPath: string; + summaryExists: boolean; + taskChecked: boolean; + nextActionAdvanced: boolean; + mustHaveCount: number; + mustHavesMentionedInSummary: number; +} + +export interface AutoUnitRuntimeRecord { + version: 1; + unitType: string; + unitId: string; + startedAt: number; + updatedAt: number; + phase: UnitRuntimePhase; + wrapupWarningSent: boolean; + timeoutAt: number | null; + lastProgressAt: number; + progressCount: number; + lastProgressKind: string; + recovery?: ExecuteTaskRecoveryStatus; + recoveryAttempts?: number; + lastRecoveryReason?: "idle" | "hard"; +} + +function runtimeDir(basePath: string): string { + return join(gsdRoot(basePath), "runtime", "units"); +} + +function runtimePath(basePath: string, unitType: string, unitId: string): string { + return join(runtimeDir(basePath), `${unitType}-${unitId.replace(/[\/]/g, "-")}.json`); +} + +export function writeUnitRuntimeRecord( + basePath: string, + unitType: string, + unitId: string, + startedAt: number, + updates: Partial = {}, +): AutoUnitRuntimeRecord { + const dir = runtimeDir(basePath); + mkdirSync(dir, { recursive: true }); + const path = runtimePath(basePath, unitType, unitId); + const prev = readUnitRuntimeRecord(basePath, unitType, unitId); + const next: AutoUnitRuntimeRecord = { + version: 1, + unitType, + unitId, + startedAt, + updatedAt: Date.now(), + phase: updates.phase ?? prev?.phase ?? "dispatched", + wrapupWarningSent: updates.wrapupWarningSent ?? prev?.wrapupWarningSent ?? false, + timeoutAt: updates.timeoutAt ?? prev?.timeoutAt ?? null, + lastProgressAt: updates.lastProgressAt ?? prev?.lastProgressAt ?? Date.now(), + progressCount: updates.progressCount ?? prev?.progressCount ?? 0, + lastProgressKind: updates.lastProgressKind ?? prev?.lastProgressKind ?? "dispatch", + recovery: updates.recovery ?? prev?.recovery, + recoveryAttempts: updates.recoveryAttempts ?? prev?.recoveryAttempts ?? 0, + lastRecoveryReason: updates.lastRecoveryReason ?? prev?.lastRecoveryReason, + }; + writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8"); + return next; +} + +export function readUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): AutoUnitRuntimeRecord | null { + const path = runtimePath(basePath, unitType, unitId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as AutoUnitRuntimeRecord; + } catch { + return null; + } +} + +export function clearUnitRuntimeRecord(basePath: string, unitType: string, unitId: string): void { + const path = runtimePath(basePath, unitType, unitId); + if (existsSync(path)) unlinkSync(path); +} + +export async function inspectExecuteTaskDurability( + basePath: string, + unitId: string, +): Promise { + const [mid, sid, tid] = unitId.split("/"); + if (!mid || !sid || !tid) return null; + + const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN"); + const summaryAbs = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); + const stateAbs = join(gsdRoot(basePath), "STATE.md"); + + const planPath = relSliceFile(basePath, mid, sid, "PLAN"); + const summaryPath = relTaskFile(basePath, mid, sid, tid, "SUMMARY"); + + const planContent = planAbs ? await loadFile(planAbs) : null; + const stateContent = existsSync(stateAbs) ? readFileSync(stateAbs, "utf-8") : ""; + const summaryExists = !!(summaryAbs && existsSync(summaryAbs)); + + const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const taskChecked = !!planContent && new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m").test(planContent); + const nextActionAdvanced = !new RegExp(`Execute ${tid}\\b`).test(stateContent); + + // Must-have coverage: load task plan and count mentions in summary + let mustHaveCount = 0; + let mustHavesMentionedInSummary = 0; + + const taskPlanAbs = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); + if (taskPlanAbs) { + const taskPlanContent = await loadFile(taskPlanAbs); + if (taskPlanContent) { + const mustHaves = parseTaskPlanMustHaves(taskPlanContent); + mustHaveCount = mustHaves.length; + if (mustHaveCount > 0 && summaryExists && summaryAbs) { + const summaryContent = await loadFile(summaryAbs); + if (summaryContent) { + mustHavesMentionedInSummary = countMustHavesMentionedInSummary(mustHaves, summaryContent); + } + } + } + } + + return { + planPath, + summaryPath, + summaryExists, + taskChecked, + nextActionAdvanced, + mustHaveCount, + mustHavesMentionedInSummary, + }; +} + +export function formatExecuteTaskRecoveryStatus(status: ExecuteTaskRecoveryStatus): string { + const missing = [] as string[]; + if (!status.summaryExists) missing.push(`summary missing (${status.summaryPath})`); + if (!status.taskChecked) missing.push(`task checkbox unchecked in ${status.planPath}`); + if (!status.nextActionAdvanced) missing.push("state next action still points at the timed-out task"); + if (status.mustHaveCount > 0 && status.mustHavesMentionedInSummary < status.mustHaveCount) { + missing.push(`must-have gap: ${status.mustHavesMentionedInSummary} of ${status.mustHaveCount} must-haves addressed in summary`); + } + return missing.length > 0 ? missing.join("; ") : "all durable task artifacts present"; +} diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts new file mode 100644 index 000000000..767e3dab7 --- /dev/null +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -0,0 +1,203 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { loadFile, parsePlan, parseRoadmap } from "./files.ts"; +import { + milestonesDir, + resolveMilestoneFile, + resolveSliceFile, + resolveSlicePath, + resolveTaskFile, + resolveTasksDir, +} from "./paths.ts"; +import { deriveState } from "./state.ts"; +import { type ValidationIssue, validateCompleteBoundary, validatePlanBoundary } from "./observability-validator.ts"; +import { getSliceBranchName } from "./worktree.ts"; + +export interface WorkspaceTaskTarget { + id: string; + title: string; + done: boolean; + planPath?: string; + summaryPath?: string; +} + +export interface WorkspaceSliceTarget { + id: string; + title: string; + done: boolean; + planPath?: string; + summaryPath?: string; + uatPath?: string; + tasksDir?: string; + branch?: string; + tasks: WorkspaceTaskTarget[]; +} + +export interface WorkspaceMilestoneTarget { + id: string; + title: string; + roadmapPath?: string; + slices: WorkspaceSliceTarget[]; +} + +export interface WorkspaceScopeTarget { + scope: string; + label: string; + kind: "project" | "milestone" | "slice" | "task"; +} + +export interface GSDWorkspaceIndex { + milestones: WorkspaceMilestoneTarget[]; + active: { + milestoneId?: string; + sliceId?: string; + taskId?: string; + phase: string; + }; + scopes: WorkspaceScopeTarget[]; + validationIssues: ValidationIssue[]; +} + +function findMilestoneIds(basePath: string): string[] { + try { + return readdirSync(milestonesDir(basePath), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => { + const match = entry.name.match(/^(M\d+)/); + return match ? match[1] : entry.name; + }) + .sort(); + } catch { + return []; + } +} + +function titleFromRoadmapHeader(content: string, fallbackId: string): string { + const roadmap = parseRoadmap(content); + return roadmap.title.replace(/^M\d+[^:]*:\s*/, "") || fallbackId; +} + +async function indexSlice(basePath: string, milestoneId: string, sliceId: string, fallbackTitle: string, done: boolean): Promise { + const planPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN") ?? undefined; + const summaryPath = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY") ?? undefined; + const uatPath = resolveSliceFile(basePath, milestoneId, sliceId, "UAT") ?? undefined; + const tasksDir = resolveTasksDir(basePath, milestoneId, sliceId) ?? undefined; + + const tasks: WorkspaceTaskTarget[] = []; + let title = fallbackTitle; + + if (planPath) { + const content = await loadFile(planPath); + if (content) { + const plan = parsePlan(content); + title = plan.title || fallbackTitle; + for (const task of plan.tasks) { + tasks.push({ + id: task.id, + title: task.title, + done: task.done, + planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined, + summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined, + }); + } + } + } + + return { + id: sliceId, + title, + done, + planPath, + summaryPath, + uatPath, + tasksDir, + branch: getSliceBranchName(milestoneId, sliceId), + tasks, + }; +} + +export async function indexWorkspace(basePath: string): Promise { + const milestoneIds = findMilestoneIds(basePath); + const milestones: WorkspaceMilestoneTarget[] = []; + const validationIssues: ValidationIssue[] = []; + + for (const milestoneId of milestoneIds) { + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP") ?? undefined; + let title = milestoneId; + const slices: WorkspaceSliceTarget[] = []; + + if (roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + title = titleFromRoadmapHeader(roadmapContent, milestoneId); + for (const slice of roadmap.slices) { + const indexedSlice = await indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done); + slices.push(indexedSlice); + validationIssues.push(...await validatePlanBoundary(basePath, milestoneId, slice.id)); + validationIssues.push(...await validateCompleteBoundary(basePath, milestoneId, slice.id)); + } + } + } + + milestones.push({ id: milestoneId, title, roadmapPath, slices }); + } + + const state = await deriveState(basePath); + const active = { + milestoneId: state.activeMilestone?.id, + sliceId: state.activeSlice?.id, + taskId: state.activeTask?.id, + phase: state.phase, + }; + + const scopes: WorkspaceScopeTarget[] = [{ scope: "project", label: "project", kind: "project" }]; + for (const milestone of milestones) { + scopes.push({ scope: milestone.id, label: `${milestone.id}: ${milestone.title}`, kind: "milestone" }); + for (const slice of milestone.slices) { + scopes.push({ scope: `${milestone.id}/${slice.id}`, label: `${milestone.id}/${slice.id}: ${slice.title}`, kind: "slice" }); + for (const task of slice.tasks) { + scopes.push({ + scope: `${milestone.id}/${slice.id}/${task.id}`, + label: `${milestone.id}/${slice.id}/${task.id}: ${task.title}`, + kind: "task", + }); + } + } + } + + return { milestones, active, scopes, validationIssues }; +} + +export async function listDoctorScopeSuggestions(basePath: string): Promise> { + const index = await indexWorkspace(basePath); + const activeSliceScope = index.active.milestoneId && index.active.sliceId + ? `${index.active.milestoneId}/${index.active.sliceId}` + : null; + + const ordered = [...index.scopes].filter(scope => scope.kind !== "project"); + ordered.sort((a, b) => { + if (activeSliceScope && a.scope === activeSliceScope) return -1; + if (activeSliceScope && b.scope === activeSliceScope) return 1; + return a.scope.localeCompare(b.scope); + }); + + return ordered.map(scope => ({ value: scope.scope, label: scope.label })); +} + +export async function getSuggestedNextCommands(basePath: string): Promise { + const index = await indexWorkspace(basePath); + const scope = index.active.milestoneId && index.active.sliceId + ? `${index.active.milestoneId}/${index.active.sliceId}` + : index.active.milestoneId; + + const commands = new Set(); + if (index.active.phase === "planning") commands.add("/gsd"); + if (index.active.phase === "executing" || index.active.phase === "summarizing") commands.add("/gsd auto"); + if (scope) commands.add(`/gsd doctor ${scope}`); + if (scope) commands.add(`/gsd doctor fix ${scope}`); + if (index.validationIssues.length > 0 && scope) commands.add(`/gsd doctor audit ${scope}`); + commands.add("/gsd status"); + return [...commands]; +} diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts new file mode 100644 index 000000000..36c153f7c --- /dev/null +++ b/src/resources/extensions/gsd/worktree.ts @@ -0,0 +1,182 @@ +/** + * GSD Slice Branch Management + * + * Simple branch-per-slice workflow. No worktrees, no registry. + * Runtime state (metrics, activity, lock, STATE.md) is gitignored + * so branch switches are clean. + * + * Flow: + * 1. ensureSliceBranch() — create + checkout slice branch + * 2. agent does work, commits + * 3. mergeSliceToMain() — checkout main, squash-merge, delete branch + */ + +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; + +export interface MergeSliceResult { + branch: string; + mergedCommitMessage: string; + deletedBranch: boolean; +} + +function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string { + try { + return execSync(`git ${args.join(" ")}`, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + } catch (error) { + if (options.allowFailure) return ""; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${message}`); + } +} + +export function getSliceBranchName(milestoneId: string, sliceId: string): string { + return `gsd/${milestoneId}/${sliceId}`; +} + +export function getMainBranch(basePath: string): string { + const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }); + if (symbolic) { + const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/); + if (match) return match[1]!; + } + + const mainExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true }); + if (mainExists) return "main"; + + const masterExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true }); + if (masterExists) return "master"; + + return runGit(basePath, ["branch", "--show-current"]); +} + +export function getCurrentBranch(basePath: string): string { + return runGit(basePath, ["branch", "--show-current"]); +} + +function branchExists(basePath: string, branch: string): boolean { + try { + runGit(basePath, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]); + return true; + } catch { + return false; + } +} + +/** + * Ensure the slice branch exists and is checked out. + * Creates the branch from main if it doesn't exist. + * Returns true if the branch was newly created. + */ +export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean { + const branch = getSliceBranchName(milestoneId, sliceId); + const current = getCurrentBranch(basePath); + + if (current === branch) return false; + + const mainBranch = getMainBranch(basePath); + let created = false; + + if (!branchExists(basePath, branch)) { + runGit(basePath, ["branch", branch, mainBranch]); + created = true; + } + + runGit(basePath, ["checkout", branch]); + return created; +} + +/** + * Auto-commit any dirty files in the current working tree. + * Returns the commit message used, or null if already clean. + */ +export function autoCommitCurrentBranch( + basePath: string, unitType: string, unitId: string, +): string | null { + const status = runGit(basePath, ["status", "--short"]); + if (!status.trim()) return null; + + runGit(basePath, ["add", "-A"]); + + const staged = runGit(basePath, ["diff", "--cached", "--stat"]); + if (!staged.trim()) return null; + + const message = `chore(${unitId}): auto-commit after ${unitType}`; + runGit(basePath, ["commit", "-m", JSON.stringify(message)]); + return message; +} + +/** + * Switch to main, auto-committing any dirty files on the current branch first. + */ +export function switchToMain(basePath: string): void { + const mainBranch = getMainBranch(basePath); + const current = getCurrentBranch(basePath); + if (current === mainBranch) return; + + // Auto-commit if dirty + autoCommitCurrentBranch(basePath, "pre-switch", current); + + runGit(basePath, ["checkout", mainBranch]); +} + +/** + * Squash-merge a completed slice branch to main. + * Expects to already be on main (call switchToMain first). + * Deletes the branch after merge. + */ +export function mergeSliceToMain( + basePath: string, milestoneId: string, sliceId: string, sliceTitle: string, +): MergeSliceResult { + const branch = getSliceBranchName(milestoneId, sliceId); + const mainBranch = getMainBranch(basePath); + + const current = getCurrentBranch(basePath); + if (current !== mainBranch) { + throw new Error(`Expected to be on ${mainBranch}, found ${current}`); + } + + if (!branchExists(basePath, branch)) { + throw new Error(`Slice branch ${branch} does not exist`); + } + + const ahead = runGit(basePath, ["rev-list", "--count", `${mainBranch}..${branch}`]); + if (Number(ahead) <= 0) { + throw new Error(`Slice branch ${branch} has no commits ahead of ${mainBranch}`); + } + + runGit(basePath, ["merge", "--squash", branch]); + const mergedCommitMessage = `feat(${milestoneId}/${sliceId}): ${sliceTitle}`; + runGit(basePath, ["commit", "-m", JSON.stringify(mergedCommitMessage)]); + runGit(basePath, ["branch", "-D", branch]); + + return { + branch, + mergedCommitMessage, + deletedBranch: true, + }; +} + +/** + * Check if we're currently on a slice branch (not main). + */ +export function isOnSliceBranch(basePath: string): boolean { + const current = getCurrentBranch(basePath); + return current.startsWith("gsd/"); +} + +/** + * Get the active slice branch name, or null if on main. + */ +export function getActiveSliceBranch(basePath: string): string | null { + try { + const current = getCurrentBranch(basePath); + return current.startsWith("gsd/") ? current : null; + } catch { + return null; + } +} diff --git a/src/resources/extensions/search-the-web/cache.ts b/src/resources/extensions/search-the-web/cache.ts new file mode 100644 index 000000000..57232207f --- /dev/null +++ b/src/resources/extensions/search-the-web/cache.ts @@ -0,0 +1,78 @@ +/** + * LRU cache with TTL — zero external dependencies. + * + * - max: maximum entries before oldest is evicted + * - ttlMs: time-to-live per entry + * + * Uses a Map (insertion-ordered) for O(1) LRU eviction: + * on every access the entry is deleted and re-inserted at the tail. + */ +export class LRUTTLCache { + private readonly max: number; + private readonly ttlMs: number; + private readonly store = new Map(); + private purgeTimer: ReturnType | null = null; + + constructor(options: { max: number; ttlMs: number }) { + this.max = options.max; + this.ttlMs = options.ttlMs; + } + + get(key: string): V | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + // Refresh to tail (most-recently-used) + this.store.delete(key); + this.store.set(key, entry); + return entry.value; + } + + set(key: string, value: V): void { + if (this.store.has(key)) { + this.store.delete(key); + } else if (this.store.size >= this.max) { + const oldest = this.store.keys().next().value; + if (oldest !== undefined) this.store.delete(oldest); + } + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } + + purgeStale(): void { + const now = Date.now(); + for (const [key, entry] of this.store) { + if (now > entry.expiresAt) this.store.delete(key); + } + } + + startPurgeInterval(intervalMs: number): void { + if (this.purgeTimer !== null) return; + this.purgeTimer = setInterval(() => this.purgeStale(), intervalMs); + // Don't keep the process alive just for cache cleanup + if (this.purgeTimer && typeof this.purgeTimer === "object" && "unref" in this.purgeTimer) { + (this.purgeTimer as NodeJS.Timeout).unref(); + } + } + + stopPurgeInterval(): void { + if (this.purgeTimer !== null) { + clearInterval(this.purgeTimer); + this.purgeTimer = null; + } + } + + clear(): void { + this.store.clear(); + } + + get size(): number { + return this.store.size; + } +} diff --git a/src/resources/extensions/search-the-web/format.ts b/src/resources/extensions/search-the-web/format.ts new file mode 100644 index 000000000..27a91e2c4 --- /dev/null +++ b/src/resources/extensions/search-the-web/format.ts @@ -0,0 +1,258 @@ +/** + * Token-efficient output formatting for search results, page content, + * and LLM context responses. + */ + +import { extractDomain } from "./url-utils"; + +export interface SearchResultFormatted { + title: string; + url: string; + description: string; + age?: string; + extra_snippets?: string[]; + [key: string]: unknown; +} + +// ============================================================================= +// Adaptive Snippet Budget +// ============================================================================= + +/** + * Compute how many extra_snippets to show per result based on total count. + * Fewer results → more snippets each. More results → fewer snippets each. + * + * This keeps total output roughly constant regardless of result count. + */ +function snippetsPerResult(resultCount: number): number { + if (resultCount <= 2) return 5; // show all available + if (resultCount <= 4) return 3; + if (resultCount <= 6) return 2; + if (resultCount <= 8) return 1; + return 0; // 9-10 results: descriptions only +} + +// ============================================================================= +// Search Results Formatting +// ============================================================================= + +export interface FormatSearchOptions { + cached?: boolean; + summary?: string; + queryCorrected?: boolean; + originalQuery?: string; + correctedQuery?: string; + moreResultsAvailable?: boolean; +} + +/** + * Format search results in a compact, token-efficient format. + * + * Produces: + * [1] Python Web Frameworks — example.com (2024-11) + * Main snippet text... + * + "additional excerpt 1" + * + "additional excerpt 2" + * + * Snippet count per result adapts to total result count. + */ +export function formatSearchResults( + query: string, + results: SearchResultFormatted[], + options: FormatSearchOptions = {} +): string { + const parts: string[] = []; + + // Header + const cacheTag = options.cached ? " (cached)" : ""; + parts.push(`Search: "${query}"${cacheTag}`); + + // Spellcheck/query correction notice + if (options.queryCorrected && options.correctedQuery) { + parts.push(`Note: Query was corrected to "${options.correctedQuery}" (original: "${options.originalQuery ?? query}")`); + } + + parts.push(""); // blank line after header + + // AI summary block if available (from Brave Summarizer) + if (options.summary) { + parts.push(`Summary: ${options.summary}\n`); + } + + if (results.length === 0) { + parts.push("No results found."); + return parts.join("\n"); + } + + const maxSnippets = snippetsPerResult(results.length); + + // Results + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const domain = extractDomain(r.url); + const age = r.age ? ` (${r.age})` : ""; + + // Compact header line: [N] Title — domain (age) + parts.push(`[${i + 1}] ${r.title} — ${domain}${age}`); + parts.push(r.url); + + // Primary description + if (r.description) { + parts.push(r.description); + } + + // Extra snippets — adaptive count based on total results + if (maxSnippets > 0 && r.extra_snippets && r.extra_snippets.length > 0) { + for (const snippet of r.extra_snippets.slice(0, maxSnippets)) { + const clean = snippet.replace(/\n/g, " ").trim(); + if (clean) parts.push(`+ ${clean}`); + } + } + + parts.push(""); // blank line between results + } + + // Pagination hint + if (options.moreResultsAvailable) { + parts.push("[More results available — increase count or refine query]"); + } + + return parts.join("\n"); +} + +// ============================================================================= +// Page Content Formatting +// ============================================================================= + +export interface FormatPageOptions { + title?: string; + charCount: number; + truncated: boolean; + originalChars?: number; + hasMore?: boolean; + nextOffset?: number; +} + +/** + * Format extracted page content with metadata header. + */ +export function formatPageContent( + url: string, + content: string, + options: FormatPageOptions +): string { + const domain = extractDomain(url); + const title = options.title ? ` — ${options.title}` : ""; + const truncNote = options.truncated && options.originalChars + ? ` [truncated from ${options.originalChars.toLocaleString()} chars]` + : ""; + const moreNote = options.hasMore && options.nextOffset + ? ` [use offset:${options.nextOffset} to continue reading]` + : ""; + + const header = `Page: ${domain}${title} (${options.charCount.toLocaleString()} chars)${truncNote}${moreNote}\n${url}\n---`; + + return `${header}\n${content}`; +} + +// ============================================================================= +// LLM Context Formatting +// ============================================================================= + +export interface LLMContextSnippet { + url: string; + title: string; + snippets: string[]; +} + +export interface LLMContextSource { + title: string; + hostname: string; + age: string[] | null; +} + +/** + * Format LLM Context API response in a compact, agent-optimized format. + * + * Output: + * Context: "query" (N sources, ~Mk tokens) + * + * [1] Page Title — domain.com (age) + * url + * Snippet text... + * --- + * Another snippet... + */ +export function formatLLMContext( + query: string, + grounding: LLMContextSnippet[], + sources: Record, + options: { cached?: boolean; tokenCount?: number } = {} +): string { + const parts: string[] = []; + + const cacheTag = options.cached ? " (cached)" : ""; + const tokenTag = options.tokenCount ? ` (~${Math.round(options.tokenCount / 1000)}k tokens)` : ""; + parts.push(`Context: "${query}" (${grounding.length} sources${tokenTag})${cacheTag}`); + parts.push(""); + + if (grounding.length === 0) { + parts.push("No relevant content found."); + return parts.join("\n"); + } + + for (let i = 0; i < grounding.length; i++) { + const g = grounding[i]; + const source = sources[g.url]; + const domain = source?.hostname || extractDomain(g.url); + const age = source?.age?.[2] ? ` (${source.age[2]})` : ""; // [2] is "N days ago" format + + parts.push(`[${i + 1}] ${g.title || source?.title || "(untitled)"} — ${domain}${age}`); + parts.push(g.url); + + // Join snippets with separator + for (const snippet of g.snippets) { + const clean = snippet.trim(); + if (clean) parts.push(clean); + } + + parts.push(""); // blank line between sources + } + + return parts.join("\n"); +} + +// ============================================================================= +// Multi-Page Formatting +// ============================================================================= + +/** + * Format multiple page extractions compactly. + */ +export function formatMultiplePages( + pages: Array<{ + url: string; + title?: string; + content: string; + charCount: number; + error?: string; + }> +): string { + const parts: string[] = []; + + for (const page of pages) { + const domain = extractDomain(page.url); + if (page.error) { + parts.push(`[✗] ${domain}: ${page.error}`); + } else { + const title = page.title ? ` — ${page.title}` : ""; + parts.push(`[✓] ${domain}${title} (${page.charCount.toLocaleString()} chars)`); + parts.push(page.url); + parts.push("---"); + parts.push(page.content); + } + parts.push(""); // separator + } + + return parts.join("\n"); +} diff --git a/src/resources/extensions/search-the-web/http.ts b/src/resources/extensions/search-the-web/http.ts new file mode 100644 index 000000000..f15c5876f --- /dev/null +++ b/src/resources/extensions/search-the-web/http.ts @@ -0,0 +1,238 @@ +/** + * HTTP utilities: retry with backoff, abort signal merging, error types, timing. + */ + +// ============================================================================= +// Error Types +// ============================================================================= + +/** Structured error for non-2xx HTTP responses. */ +export class HttpError extends Error { + readonly statusCode: number; + readonly response?: Response; + + constructor(message: string, statusCode: number, response?: Response) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + this.response = response; + Object.setPrototypeOf(this, HttpError.prototype); + } +} + +/** Categorized error types for agent-friendly error handling. */ +export type SearchErrorKind = + | "auth_error" // 401/403 — bad or missing API key + | "rate_limited" // 429 — too many requests + | "network_error" // DNS, timeout, connection refused + | "server_error" // 5xx + | "invalid_request" // 400, bad params + | "not_found" // 404 + | "unknown"; + +export function classifyError(err: unknown): { kind: SearchErrorKind; message: string; retryAfterMs?: number } { + if (err instanceof HttpError) { + const code = err.statusCode; + if (code === 401 || code === 403) { + return { kind: "auth_error", message: `HTTP ${code}: Invalid or missing API key. Use secure_env_collect to set BRAVE_API_KEY.` }; + } + if (code === 429) { + let retryAfterMs: number | undefined; + const retryAfter = err.response?.headers.get("Retry-After"); + if (retryAfter) { + const seconds = parseFloat(retryAfter); + if (!isNaN(seconds)) retryAfterMs = seconds * 1000; + } + return { kind: "rate_limited", message: `Rate limited (HTTP 429). ${retryAfterMs ? `Retry after ${Math.ceil(retryAfterMs / 1000)}s.` : "Wait before retrying."}`, retryAfterMs }; + } + if (code === 400) { + return { kind: "invalid_request", message: `Bad request (HTTP 400): ${err.message}` }; + } + if (code === 404) return { kind: "not_found", message: `Not found (HTTP 404)` }; + if (code >= 500) return { kind: "server_error", message: `Server error (HTTP ${code}): ${err.message}` }; + return { kind: "unknown", message: `HTTP ${code}: ${err.message}` }; + } + if (err instanceof TypeError) { + return { kind: "network_error", message: `Network error: ${(err as Error).message}` }; + } + const msg = (err as Error)?.message ?? String(err); + if (msg.includes("abort") || msg.includes("timeout")) { + return { kind: "network_error", message: `Request timed out` }; + } + return { kind: "unknown", message: msg }; +} + +// ============================================================================= +// Rate Limit Info +// ============================================================================= + +export interface RateLimitInfo { + remaining?: number; + limit?: number; + reset?: number; // epoch seconds +} + +/** Extract rate limit headers from a Brave API response. */ +export function extractRateLimitInfo(response: Response): RateLimitInfo | undefined { + const remaining = response.headers.get("x-ratelimit-remaining"); + const limit = response.headers.get("x-ratelimit-limit"); + const reset = response.headers.get("x-ratelimit-reset"); + if (!remaining && !limit) return undefined; + return { + remaining: remaining ? parseInt(remaining, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + reset: reset ? parseInt(reset, 10) : undefined, + }; +} + +// ============================================================================= +// Timing +// ============================================================================= + +export interface TimedResponse { + response: Response; + latencyMs: number; + rateLimit?: RateLimitInfo; +} + +// ============================================================================= +// Retry Logic +// ============================================================================= + +function isRetryable(error: unknown): boolean { + if (error instanceof HttpError) { + return error.statusCode === 429 || error.statusCode >= 500; + } + if (error instanceof TypeError) return true; + return false; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Merge multiple AbortSignals — aborts as soon as any fires. */ +export function anySignal(signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const sig of signals) { + if (sig.aborted) { + controller.abort(sig.reason); + break; + } + sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true }); + } + return controller.signal; +} + +/** + * Fetch with automatic retry and full-jitter exponential backoff. + * + * - maxRetries: additional attempts after the first (total = maxRetries + 1) + * - Respects Retry-After header on 429 responses + * - Each attempt uses a 30-second AbortSignal timeout + * - Non-retryable errors thrown immediately + */ +export async function fetchWithRetry( + url: string, + options: RequestInit, + maxRetries: number = 2 +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), 30_000); + + const callerSignal = options.signal as AbortSignal | undefined; + const signal = callerSignal + ? anySignal([callerSignal, timeoutController.signal]) + : timeoutController.signal; + + try { + const response = await fetch(url, { ...options, signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response + ); + } + return response; + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + + if (!isRetryable(err)) throw err; + + if (attempt < maxRetries) { + let delayMs: number; + if (err instanceof HttpError && err.statusCode === 429 && err.response) { + const retryAfter = err.response.headers.get("Retry-After"); + if (retryAfter) { + const seconds = parseFloat(retryAfter); + delayMs = isNaN(seconds) ? 1000 : seconds * 1000; + } else { + delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt); + } + } else { + delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt); + } + await sleep(delayMs); + } + } + } + + throw lastError; +} + +/** + * Simple fetch with timeout, no retry. For content extraction where + * we want to fail fast. + */ +export async function fetchSimple( + url: string, + options: RequestInit & { timeoutMs?: number } = {} +): Promise { + const { timeoutMs = 15_000, ...fetchOpts } = options; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const callerSignal = fetchOpts.signal as AbortSignal | undefined; + const signal = callerSignal + ? anySignal([callerSignal, controller.signal]) + : controller.signal; + + try { + const response = await fetch(url, { ...fetchOpts, signal }); + clearTimeout(timeoutId); + if (!response.ok) { + throw new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response + ); + } + return response; + } catch (err) { + clearTimeout(timeoutId); + throw err; + } +} + +/** + * Fetch with retry AND timing/rate-limit extraction. + * Wraps fetchWithRetry and returns latency + rate limit info. + */ +export async function fetchWithRetryTimed( + url: string, + options: RequestInit, + maxRetries: number = 2 +): Promise { + const start = performance.now(); + const response = await fetchWithRetry(url, options, maxRetries); + const latencyMs = Math.round(performance.now() - start); + const rateLimit = extractRateLimitInfo(response); + return { response, latencyMs, rateLimit }; +} diff --git a/src/resources/extensions/search-the-web/index.ts b/src/resources/extensions/search-the-web/index.ts new file mode 100644 index 000000000..d19d181ea --- /dev/null +++ b/src/resources/extensions/search-the-web/index.ts @@ -0,0 +1,66 @@ +/** + * Web Search Extension v3 + * + * Provides three tools for grounding the agent in real-world web content: + * + * search-the-web — Rich web search with extra snippets, freshness filtering, + * domain scoping, AI summarizer, and compact output format. + * Returns links and snippets for selective browsing. + * + * fetch_page — Extract clean markdown from any URL via Jina Reader. + * Supports offset-based continuation, CSS selector targeting, + * and content-type-aware extraction. + * + * search_and_read — Single-call search + content extraction via Brave LLM Context API. + * Returns pre-extracted, relevance-scored page content. + * Best when you need content, not just links. + * + * v3 improvements over v2: + * - search_and_read: New tool — Brave LLM Context API (search + read in one call) + * - Structured error taxonomy: auth_error, rate_limited, network_error, etc. + * - Spellcheck surfacing: query corrections from Brave shown to agent + * - Latency tracking: API call timing in details for observability + * - Rate limit info: remaining quota surfaced when available + * - more_results_available: pagination hints from Brave + * - Adaptive snippet budget: snippet count adapts to result count + * - fetch_page offset: continuation reading for long pages + * - fetch_page selector: CSS selector targeting via Jina X-Target-Selector + * - fetch_page diagnostics: Jina failure reasons surfaced in details + * - Content-type awareness: JSON passthrough, PDF detection + * - Cache timer cleanup: purge timers use unref() to not block process exit + * + * Environment variables: + * BRAVE_API_KEY — Required for search. Get one at brave.com/search/api + * JINA_API_KEY — Optional. Higher rate limits for page extraction. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { registerSearchTool } from "./tool-search"; +import { registerFetchPageTool } from "./tool-fetch-page"; +import { registerLLMContextTool } from "./tool-llm-context"; + +export default function (pi: ExtensionAPI) { + // Register all tools + registerSearchTool(pi); + registerFetchPageTool(pi); + registerLLMContextTool(pi); + + // Startup diagnostics + pi.on("session_start", async (_event, ctx) => { + const hasBrave = !!process.env.BRAVE_API_KEY; + const hasJina = !!process.env.JINA_API_KEY; + + if (!hasBrave) { + ctx.ui.notify( + "Web search: Set BRAVE_API_KEY for web search capability", + "warning" + ); + } + + const parts: string[] = ["Web search v3 loaded"]; + if (hasBrave) parts.push("Brave ✓"); + if (hasJina) parts.push("Jina ✓"); + + ctx.ui.notify(parts.join(" · "), "info"); + }); +} diff --git a/src/resources/extensions/search-the-web/tool-fetch-page.ts b/src/resources/extensions/search-the-web/tool-fetch-page.ts new file mode 100644 index 000000000..42fd8b55e --- /dev/null +++ b/src/resources/extensions/search-the-web/tool-fetch-page.ts @@ -0,0 +1,519 @@ +/** + * fetch_page tool — Extract clean markdown from any URL. + * + * v3 improvements: + * - offset parameter for continuation reading (like file read offsets) + * - selector parameter for Jina's X-Target-Selector (extract specific sections) + * - Jina failure diagnostics surfaced in details + * - Content-type awareness (JSON passthrough, PDF detection) + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +import { LRUTTLCache } from "./cache"; +import { fetchSimple, HttpError } from "./http"; +import { extractDomain } from "./url-utils"; +import { formatPageContent, type FormatPageOptions } from "./format"; + +// ============================================================================= +// Cache +// ============================================================================= + +interface CachedPage { + content: string; + title?: string; + source: "jina" | "direct"; +} + +// Page content cache: max 30 entries, 15-minute TTL +const pageCache = new LRUTTLCache({ max: 30, ttlMs: 900_000 }); +pageCache.startPurgeInterval(120_000); + +// ============================================================================= +// Jina Reader +// ============================================================================= + +/** + * Fetch page content via Jina Reader API. + * Returns content + metadata, or throws with a descriptive error. + */ +async function fetchViaJina( + url: string, + options: { signal?: AbortSignal; selector?: string } = {} +): Promise<{ content: string; title?: string }> { + const jinaUrl = `https://r.jina.ai/${url}`; + + const headers: Record = { + "Accept": "text/plain", + "X-Return-Format": "markdown", + "X-No-Cache": "false", + }; + + // Use Jina API key if available for higher rate limits + const jinaKey = process.env.JINA_API_KEY; + if (jinaKey) { + headers["Authorization"] = `Bearer ${jinaKey}`; + } + + // Target specific CSS selector on the page + if (options.selector) { + headers["X-Target-Selector"] = options.selector; + } + + const response = await fetchSimple(jinaUrl, { + method: "GET", + headers, + signal: options.signal, + timeoutMs: 20_000, + }); + + const text = await response.text(); + + // Jina returns markdown with a title line at the top + // Format: "Title: \nURL Source: <url>\n\n<content>" + let title: string | undefined; + let content = text; + + const titleMatch = text.match(/^Title:\s*(.+)\n/); + if (titleMatch) { + title = titleMatch[1].trim(); + content = text.replace(/^Title:\s*.+\n/, ""); + } + + // Strip the URL Source line + content = content.replace(/^URL Source:\s*.+\n\n?/, ""); + + // Strip Markdown images to save tokens + content = content.replace(/!\[([^\]]*)\]\([^)]+\)/g, ""); + + // Collapse excessive whitespace + content = content.replace(/\n{4,}/g, "\n\n\n"); + + return { content: content.trim(), title }; +} + +/** + * Basic fallback: fetch raw HTML and do crude text extraction. + */ +async function fetchDirectFallback( + url: string, + signal?: AbortSignal +): Promise<{ content: string; title?: string; contentType?: string }> { + const response = await fetchSimple(url, { + method: "GET", + headers: { + "Accept": "text/html,application/xhtml+xml,application/json,text/plain", + "User-Agent": "Mozilla/5.0 (compatible; pi-coding-agent/1.0)", + }, + signal, + timeoutMs: 15_000, + }); + + const contentType = response.headers.get("content-type") || ""; + + // JSON passthrough — return formatted JSON directly + if (contentType.includes("application/json")) { + const text = await response.text(); + try { + const parsed = JSON.parse(text); + return { + content: "```json\n" + JSON.stringify(parsed, null, 2) + "\n```", + title: undefined, + contentType: "application/json", + }; + } catch { + return { content: text, title: undefined, contentType }; + } + } + + // Plain text passthrough + if (contentType.includes("text/plain")) { + const text = await response.text(); + return { content: text, title: undefined, contentType: "text/plain" }; + } + + // PDF detection — can't extract, but tell the agent + if (contentType.includes("application/pdf")) { + return { + content: "[This URL is a PDF document. Content extraction is not supported for PDFs.]", + title: undefined, + contentType: "application/pdf", + }; + } + + const html = await response.text(); + + // Extract title + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : undefined; + + // Strip tags, decode entities, collapse whitespace + let text = html + .replace(/<script[\s\S]*?<\/script>/gi, "") + .replace(/<style[\s\S]*?<\/style>/gi, "") + .replace(/<nav[\s\S]*?<\/nav>/gi, "") + .replace(/<header[\s\S]*?<\/header>/gi, "") + .replace(/<footer[\s\S]*?<\/footer>/gi, "") + .replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|section|article)[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .replace(/[ \t]+/g, " ") + .replace(/\n[ \t]+/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return { content: text, title, contentType }; +} + +// ============================================================================= +// Smart Truncation +// ============================================================================= + +/** + * Truncate page content to a target character count, trying to break + * at paragraph boundaries rather than mid-sentence. + */ +function smartTruncate( + content: string, + maxChars: number, + offset: number = 0 +): { content: string; truncated: boolean; hasMore: boolean; nextOffset?: number } { + // Apply offset first + const sliced = offset > 0 ? content.slice(offset) : content; + + if (sliced.length <= maxChars) { + return { content: sliced, truncated: false, hasMore: false }; + } + + // Find the last paragraph break before maxChars + const window = sliced.slice(0, maxChars); + const lastParagraph = window.lastIndexOf("\n\n"); + const lastSentence = window.lastIndexOf(". "); + const lastNewline = window.lastIndexOf("\n"); + + // Prefer paragraph > sentence > newline > hard cut + let cutPoint = maxChars; + if (lastParagraph > maxChars * 0.6) { + cutPoint = lastParagraph; + } else if (lastSentence > maxChars * 0.6) { + cutPoint = lastSentence + 1; + } else if (lastNewline > maxChars * 0.6) { + cutPoint = lastNewline; + } + + const nextOffset = offset + cutPoint; + const hasMore = nextOffset < content.length; + + return { + content: sliced.slice(0, cutPoint).trim() + "\n\n[... content truncated]", + truncated: true, + hasMore, + nextOffset: hasMore ? nextOffset : undefined, + }; +} + +// ============================================================================= +// Single page fetch (shared between single and multi modes) +// ============================================================================= + +interface FetchPageResult { + content: string; + title?: string; + source: "jina" | "direct"; + jinaError?: string; + contentType?: string; + originalChars: number; +} + +async function fetchOnePage( + url: string, + options: { signal?: AbortSignal; selector?: string } +): Promise<FetchPageResult> { + let pageContent: string; + let pageTitle: string | undefined; + let source: "jina" | "direct" = "jina"; + let jinaError: string | undefined; + let contentType: string | undefined; + + try { + const result = await fetchViaJina(url, options); + pageContent = result.content; + pageTitle = result.title; + } catch (err) { + // Capture Jina failure reason for diagnostics + jinaError = err instanceof HttpError + ? `Jina HTTP ${err.statusCode}` + : (err as Error).message ?? String(err); + source = "direct"; + + const result = await fetchDirectFallback(url, options.signal); + pageContent = result.content; + pageTitle = result.title; + contentType = result.contentType; + } + + return { + content: pageContent, + title: pageTitle, + source, + jinaError, + contentType, + originalChars: pageContent.length, + }; +} + +// ============================================================================= +// Details Interface +// ============================================================================= + +interface FetchPageDetails { + url: string; + title?: string; + charCount: number; + originalChars?: number; + truncated: boolean; + cached: boolean; + source?: "jina" | "direct"; + jinaError?: string; + contentType?: string; + hasMore?: boolean; + nextOffset?: number; + offset?: number; + selector?: string; + error?: string; +} + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export function registerFetchPageTool(pi: ExtensionAPI) { + pi.registerTool({ + name: "fetch_page", + label: "Fetch Page", + description: + "Fetch a web page and extract its content as clean markdown. " + + "Use this to read the full content of URLs found via search-the-web. " + + "Uses Jina Reader for high-quality markdown extraction. " + + "Control the amount of content returned with maxChars (default: 8000, max: 30000).", + promptSnippet: "Fetch and extract clean content from a web page URL as markdown", + promptGuidelines: [ + "Use fetch_page to read the content of URLs found via search-the-web when you need more detail than snippets provide.", + "Start with the default maxChars (8000). Increase only if the first fetch lacks the detail you need.", + "For very long pages, use a smaller maxChars and increase if needed — this saves context tokens.", + "The extracted content is already clean markdown — no HTML tags, no navigation, no ads.", + ], + parameters: Type.Object({ + url: Type.String({ description: "URL to fetch and extract content from" }), + maxChars: Type.Optional( + Type.Number({ + minimum: 1000, + maximum: 30000, + default: 8000, + description: "Maximum characters of content to return (default: 8000, max: 30000). Controls context token usage.", + }) + ), + offset: Type.Optional( + Type.Number({ + minimum: 0, + description: "Character offset to start reading from (for continuation of truncated pages). Use the nextOffset value from a previous fetch_page result.", + }) + ), + selector: Type.Optional( + Type.String({ + description: "CSS selector to extract only a specific section of the page (e.g., 'main', 'article', '.api-docs'). Reduces noise and token usage.", + }) + ), + }), + + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) { + return { content: [{ type: "text", text: "Fetch cancelled." }] }; + } + + const maxChars = params.maxChars ?? 8000; + const offset = params.offset ?? 0; + const url = params.url.trim(); + + // Validate URL + try { + new URL(url); + } catch { + return { + content: [{ type: "text", text: `Invalid URL: ${url}` }], + isError: true, + details: { error: "Invalid URL", url } satisfies Partial<FetchPageDetails>, + }; + } + + // ------------------------------------------------------------------ + // Cache lookup (full content cached, offset/truncation applied after) + // ------------------------------------------------------------------ + const cacheKey = params.selector ? `${url}|sel:${params.selector}` : url; + const cached = pageCache.get(cacheKey); + + if (cached) { + const trunc = smartTruncate(cached.content, maxChars, offset); + const opts: FormatPageOptions = { + title: cached.title, + charCount: trunc.content.length, + truncated: trunc.truncated, + originalChars: trunc.truncated ? cached.content.length : undefined, + hasMore: trunc.hasMore, + nextOffset: trunc.nextOffset, + }; + const output = formatPageContent(url, trunc.content, opts); + + const finalTruncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + const details: FetchPageDetails = { + url, + title: cached.title, + charCount: trunc.content.length, + originalChars: cached.content.length, + truncated: trunc.truncated, + cached: true, + source: cached.source, + hasMore: trunc.hasMore, + nextOffset: trunc.nextOffset, + offset: offset || undefined, + }; + return { + content: [{ type: "text", text: finalTruncation.content }], + details, + }; + } + + const domain = extractDomain(url); + onUpdate?.({ content: [{ type: "text", text: `Fetching ${domain}...` }] }); + + // ------------------------------------------------------------------ + // Fetch page content + // ------------------------------------------------------------------ + let result: FetchPageResult; + try { + result = await fetchOnePage(url, { signal, selector: params.selector }); + } catch (err) { + const message = err instanceof HttpError + ? `HTTP ${err.statusCode}` + : (err as Error).message ?? String(err); + return { + content: [{ type: "text", text: `Failed to fetch ${domain}: ${message}` }], + isError: true, + details: { error: message, url } satisfies Partial<FetchPageDetails>, + }; + } + + // Check for empty content + if (!result.content || result.content.length < 50) { + return { + content: [{ type: "text", text: `Page at ${domain} returned no extractable content.` }], + details: { url, charCount: 0, source: result.source, cached: false, truncated: false, jinaError: result.jinaError } satisfies FetchPageDetails, + }; + } + + // Cache the full content + pageCache.set(cacheKey, { content: result.content, title: result.title, source: result.source }); + + // Smart truncate with offset + const trunc = smartTruncate(result.content, maxChars, offset); + + const opts: FormatPageOptions = { + title: result.title, + charCount: trunc.content.length, + truncated: trunc.truncated, + originalChars: trunc.truncated ? result.originalChars : undefined, + hasMore: trunc.hasMore, + nextOffset: trunc.nextOffset, + }; + + const output = formatPageContent(url, trunc.content, opts); + + const finalTruncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let content = finalTruncation.content; + if (finalTruncation.truncated) { + const tempFile = await pi.writeTempFile(output, { prefix: "fetch-page-" }); + content += `\n\n[Truncated to fit context. Full content: ${tempFile}]`; + } + + const details: FetchPageDetails = { + url, + title: result.title, + charCount: trunc.content.length, + originalChars: result.originalChars, + truncated: trunc.truncated, + cached: false, + source: result.source, + jinaError: result.jinaError, + contentType: result.contentType, + hasMore: trunc.hasMore, + nextOffset: trunc.nextOffset, + offset: offset || undefined, + selector: params.selector, + }; + + return { + content: [{ type: "text", text: content }], + details, + }; + }, + + renderCall(args, theme) { + const domain = extractDomain(args.url); + let text = theme.fg("toolTitle", theme.bold("fetch_page ")); + text += theme.fg("accent", domain); + + const meta: string[] = []; + if (args.maxChars && args.maxChars !== 8000) meta.push(`max ${(args.maxChars / 1000).toFixed(0)}k`); + if (args.offset) meta.push(`offset:${args.offset}`); + if (args.selector) meta.push(`sel:"${args.selector}"`); + if (meta.length > 0) { + text += " " + theme.fg("dim", `(${meta.join(", ")})`); + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as FetchPageDetails | undefined; + if (details?.error) { + return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0); + } + + const domain = extractDomain(details?.url || ""); + const title = details?.title ? ` — ${details.title}` : ""; + const chars = details?.charCount ? `${(details.charCount / 1000).toFixed(1)}k chars` : ""; + const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : ""; + const sourceTag = details?.source === "direct" ? theme.fg("dim", " [direct]") : ""; + const truncTag = details?.truncated && details?.originalChars + ? theme.fg("dim", ` [${(details.originalChars / 1000).toFixed(0)}k total]`) + : ""; + const moreTag = details?.hasMore && details?.nextOffset + ? theme.fg("accent", ` [more→offset:${details.nextOffset}]`) + : ""; + const jinaTag = details?.jinaError + ? theme.fg("warning", ` [jina failed: ${details.jinaError}]`) + : ""; + + let text = theme.fg("success", `✓ ${domain}${title}`) + ` ${chars}` + + cacheTag + sourceTag + truncTag + moreTag + jinaTag; + + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + const preview = content.text.split("\n").slice(0, 8).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + } + } + + return new Text(text, 0, 0); + }, + }); +} diff --git a/src/resources/extensions/search-the-web/tool-llm-context.ts b/src/resources/extensions/search-the-web/tool-llm-context.ts new file mode 100644 index 000000000..ab37604ee --- /dev/null +++ b/src/resources/extensions/search-the-web/tool-llm-context.ts @@ -0,0 +1,404 @@ +/** + * search_and_read tool — Brave LLM Context API. + * + * Single-call web search + page content extraction optimized for AI agents. + * Unlike search-the-web → fetch_page (two steps), this returns pre-extracted, + * relevance-scored page content in one API call. + * + * Best for: "I need to know about X" — when you want content, not just links. + * Use search-the-web when you want links/URLs to browse selectively. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +import { LRUTTLCache } from "./cache"; +import { fetchWithRetryTimed, HttpError, classifyError, type RateLimitInfo } from "./http"; +import { normalizeQuery, extractDomain } from "./url-utils"; +import { formatLLMContext, type LLMContextSnippet, type LLMContextSource } from "./format"; + +// ============================================================================= +// Types +// ============================================================================= + +interface BraveLLMContextResponse { + grounding?: { + generic?: Array<{ + url: string; + title: string; + snippets: string[]; + }>; + poi?: { + name: string; + url: string; + title: string; + snippets: string[]; + } | null; + map?: Array<{ + name: string; + url: string; + title: string; + snippets: string[]; + }>; + }; + sources?: Record<string, { + title: string; + hostname: string; + age: string[] | null; + }>; +} + +interface CachedLLMContext { + grounding: LLMContextSnippet[]; + sources: Record<string, LLMContextSource>; + estimatedTokens: number; +} + +interface LLMContextDetails { + query: string; + sourceCount: number; + snippetCount: number; + estimatedTokens: number; + cached: boolean; + latencyMs?: number; + rateLimit?: RateLimitInfo; + threshold?: string; + maxTokens?: number; + errorKind?: string; + error?: string; + retryAfterMs?: number; +} + +// ============================================================================= +// Cache +// ============================================================================= + +// LLM Context cache: max 50 entries, 10-minute TTL +const contextCache = new LRUTTLCache<CachedLLMContext>({ max: 50, ttlMs: 600_000 }); +contextCache.startPurgeInterval(60_000); + +// ============================================================================= +// Helpers +// ============================================================================= + +function getBraveApiKey(): string { + return process.env.BRAVE_API_KEY || ""; +} + +function braveHeaders(): Record<string, string> { + return { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": getBraveApiKey(), + }; +} + +/** Rough token estimate: ~4 chars per token for English text. */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export function registerLLMContextTool(pi: ExtensionAPI) { + pi.registerTool({ + name: "search_and_read", + label: "Search & Read", + description: + "Search the web AND read page content in a single call. Returns pre-extracted, " + + "relevance-scored text from multiple pages — no separate fetch_page needed. " + + "Powered by Brave's LLM Context API. Best when you need content, not just links. " + + "For selective URL browsing, use search-the-web + fetch_page instead.", + promptSnippet: "Search and read web page content in one step", + promptGuidelines: [ + "Use search_and_read when you need actual page content about a topic — it searches and extracts in one call.", + "Prefer search_and_read over search-the-web + fetch_page when you just need to learn about something.", + "Use search-the-web when you need to browse specific URLs, control which pages to read, or want just links.", + "Start with the default maxTokens (8192). Use smaller values (2048-4096) for simple factual queries.", + "Use threshold='strict' for focused, high-relevance results. Use 'lenient' for broad coverage.", + ], + parameters: Type.Object({ + query: Type.String({ description: "Search query — what you want to learn about" }), + maxTokens: Type.Optional( + Type.Number({ + minimum: 1024, + maximum: 32768, + default: 8192, + description: "Approximate maximum tokens of content to return (default: 8192). Lower = faster + cheaper inference.", + }) + ), + maxUrls: Type.Optional( + Type.Number({ + minimum: 1, + maximum: 20, + default: 10, + description: "Maximum number of source URLs to include (default: 10).", + }) + ), + threshold: Type.Optional( + StringEnum(["strict", "balanced", "lenient"] as const, { + description: "Relevance threshold. 'strict' = fewer but more relevant. 'balanced' (default). 'lenient' = broader coverage.", + }) + ), + count: Type.Optional( + Type.Number({ + minimum: 1, + maximum: 50, + default: 20, + description: "Maximum search results to consider (default: 20). More = broader but slower.", + }) + ), + }), + + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) { + return { content: [{ type: "text", text: "Search cancelled." }] }; + } + + const apiKey = getBraveApiKey(); + if (!apiKey) { + return { + content: [{ type: "text", text: "Search unavailable: BRAVE_API_KEY is not set. Use secure_env_collect to set BRAVE_API_KEY." }], + isError: true, + details: { errorKind: "auth_error", error: "BRAVE_API_KEY not set" } satisfies Partial<LLMContextDetails>, + }; + } + + const maxTokens = params.maxTokens ?? 8192; + const maxUrls = params.maxUrls ?? 10; + const threshold = params.threshold ?? "balanced"; + const count = params.count ?? 20; + + // ------------------------------------------------------------------ + // Cache lookup + // ------------------------------------------------------------------ + const cacheKey = normalizeQuery(params.query) + `|t:${maxTokens}|u:${maxUrls}|th:${threshold}|c:${count}`; + const cached = contextCache.get(cacheKey); + + if (cached) { + const output = formatLLMContext(params.query, cached.grounding, cached.sources, { + cached: true, + tokenCount: cached.estimatedTokens, + }); + + const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let content = truncation.content; + if (truncation.truncated) { + const tempFile = await pi.writeTempFile(output, { prefix: "llm-context-" }); + content += `\n\n[Truncated. Full content: ${tempFile}]`; + } + + const totalSnippets = cached.grounding.reduce((sum, g) => sum + g.snippets.length, 0); + const details: LLMContextDetails = { + query: params.query, + sourceCount: cached.grounding.length, + snippetCount: totalSnippets, + estimatedTokens: cached.estimatedTokens, + cached: true, + threshold, + maxTokens, + }; + + return { content: [{ type: "text", text: content }], details }; + } + + onUpdate?.({ content: [{ type: "text", text: `Searching & reading about "${params.query}"...` }] }); + + try { + // ------------------------------------------------------------------ + // Build LLM Context API request + // ------------------------------------------------------------------ + const url = new URL("https://api.search.brave.com/res/v1/llm/context"); + url.searchParams.append("q", params.query); + url.searchParams.append("count", String(count)); + url.searchParams.append("maximum_number_of_tokens", String(maxTokens)); + url.searchParams.append("maximum_number_of_urls", String(maxUrls)); + url.searchParams.append("context_threshold_mode", threshold); + + // Use a custom fetch flow to read error bodies from the Brave API + let timed; + try { + timed = await fetchWithRetryTimed(url.toString(), { + method: "GET", + headers: braveHeaders(), + signal, + }, 2); + } catch (fetchErr) { + // Try to extract Brave's structured error detail from the response body. + // This is especially useful for plan/subscription errors (OPTION_NOT_IN_PLAN). + let errorMessage: string | undefined; + let errorKindOverride: string | undefined; + if (fetchErr instanceof HttpError && fetchErr.response) { + try { + const body = await fetchErr.response.clone().json().catch(() => null); + if (body?.error?.detail) { + errorMessage = body.error.detail; + if (body.error.code === "OPTION_NOT_IN_PLAN") { + errorKindOverride = "plan_error"; + errorMessage = `LLM Context API not available on your current Brave plan. ${body.error.detail} Upgrade at https://api-dashboard.search.brave.com/app/subscriptions — or use search-the-web + fetch_page as an alternative.`; + } + } + } catch { /* body already consumed or parse error — use generic message */ } + } + const classified = classifyError(fetchErr); + const message = errorMessage || classified.message; + return { + content: [{ type: "text", text: `search_and_read unavailable: ${message}` }], + details: { + errorKind: errorKindOverride || classified.kind, + error: message, + retryAfterMs: classified.retryAfterMs, + query: params.query, + } satisfies Partial<LLMContextDetails>, + isError: true, + }; + } + + const data: BraveLLMContextResponse = await timed.response.json(); + + // ------------------------------------------------------------------ + // Normalize response + // ------------------------------------------------------------------ + const grounding: LLMContextSnippet[] = []; + + if (data.grounding?.generic) { + for (const item of data.grounding.generic) { + if (item.snippets && item.snippets.length > 0) { + grounding.push({ + url: item.url, + title: item.title, + snippets: item.snippets, + }); + } + } + } + + // Include POI data if present + if (data.grounding?.poi && data.grounding.poi.snippets?.length) { + grounding.push({ + url: data.grounding.poi.url, + title: data.grounding.poi.title || data.grounding.poi.name, + snippets: data.grounding.poi.snippets, + }); + } + + // Include map data if present + if (data.grounding?.map) { + for (const item of data.grounding.map) { + if (item.snippets?.length) { + grounding.push({ + url: item.url, + title: item.title || item.name, + snippets: item.snippets, + }); + } + } + } + + const sources: Record<string, LLMContextSource> = {}; + if (data.sources) { + for (const [sourceUrl, sourceInfo] of Object.entries(data.sources)) { + sources[sourceUrl] = { + title: sourceInfo.title, + hostname: sourceInfo.hostname, + age: sourceInfo.age, + }; + } + } + + // Estimate total token count from all snippets + const allText = grounding.map(g => g.snippets.join(" ")).join(" "); + const estimatedTokens = estimateTokens(allText); + + // Cache the results + contextCache.set(cacheKey, { grounding, sources, estimatedTokens }); + + // ------------------------------------------------------------------ + // Format output + // ------------------------------------------------------------------ + const output = formatLLMContext(params.query, grounding, sources, { + tokenCount: estimatedTokens, + }); + + const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let content = truncation.content; + + if (truncation.truncated) { + const tempFile = await pi.writeTempFile(output, { prefix: "llm-context-" }); + content += `\n\n[Truncated. Full content: ${tempFile}]`; + } + + const totalSnippets = grounding.reduce((sum, g) => sum + g.snippets.length, 0); + const details: LLMContextDetails = { + query: params.query, + sourceCount: grounding.length, + snippetCount: totalSnippets, + estimatedTokens, + cached: false, + latencyMs: timed.latencyMs, + rateLimit: timed.rateLimit, + threshold, + maxTokens, + }; + + return { content: [{ type: "text", text: content }], details }; + } catch (error) { + const classified = classifyError(error); + return { + content: [{ type: "text", text: `Search failed: ${classified.message}` }], + details: { + errorKind: classified.kind, + error: classified.message, + query: params.query, + } satisfies Partial<LLMContextDetails>, + isError: true, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("search_and_read ")); + text += theme.fg("muted", `"${args.query}"`); + + const meta: string[] = []; + if (args.maxTokens && args.maxTokens !== 8192) meta.push(`${(args.maxTokens / 1000).toFixed(0)}k tokens`); + if (args.threshold && args.threshold !== "balanced") meta.push(`threshold:${args.threshold}`); + if (args.maxUrls && args.maxUrls !== 10) meta.push(`${args.maxUrls} urls`); + if (meta.length > 0) { + text += " " + theme.fg("dim", `(${meta.join(", ")})`); + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as LLMContextDetails | undefined; + if (details?.errorKind || details?.error) { + const kindTag = details.errorKind ? theme.fg("dim", ` [${details.errorKind}]`) : ""; + return new Text(theme.fg("error", `✗ ${details.error ?? "Search failed"}`) + kindTag, 0, 0); + } + + const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : ""; + const latencyTag = details?.latencyMs ? theme.fg("dim", ` ${details.latencyMs}ms`) : ""; + const tokenTag = details?.estimatedTokens + ? theme.fg("dim", ` ~${(details.estimatedTokens / 1000).toFixed(1)}k tokens`) + : ""; + + let text = theme.fg("success", + `✓ ${details?.sourceCount ?? 0} sources, ${details?.snippetCount ?? 0} snippets for "${details?.query}"`) + + tokenTag + cacheTag + latencyTag; + + if (expanded && result.content[0]?.type === "text") { + const preview = result.content[0].text.split("\n").slice(0, 10).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + } + + return new Text(text, 0, 0); + }, + }); +} diff --git a/src/resources/extensions/search-the-web/tool-search.ts b/src/resources/extensions/search-the-web/tool-search.ts new file mode 100644 index 000000000..883e6f056 --- /dev/null +++ b/src/resources/extensions/search-the-web/tool-search.ts @@ -0,0 +1,503 @@ +/** + * search-the-web tool — Rich web search with full Brave API support. + * + * v3 improvements: + * - Structured error taxonomy (auth_error, rate_limited, network_error, etc.) + * - Spellcheck/query correction surfacing + * - Latency tracking in details + * - more_results_available from Brave response + * - Adaptive snippet budget (fewer results = more snippets each) + * - Rate limit info in details + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateHead, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +import { LRUTTLCache } from "./cache"; +import { fetchWithRetryTimed, fetchWithRetry, classifyError, type RateLimitInfo } from "./http"; +import { normalizeQuery, toDedupeKey, detectFreshness } from "./url-utils"; +import { formatSearchResults, type SearchResultFormatted, type FormatSearchOptions } from "./format"; + +// ============================================================================= +// Types +// ============================================================================= + +interface BraveWebResult { + title: string; + url: string; + description: string; + age?: string; + page_age?: string; + language?: string; + extra_snippets?: string[]; + meta_url?: { scheme?: string; netloc?: string; hostname?: string; path?: string }; + [key: string]: unknown; +} + +interface BraveSummarizerResponse { + type?: string; + status?: number; + title?: string; + summary?: Array<{ type: string; data: string }>; + enrichments?: unknown; + [key: string]: unknown; +} + +interface BraveSearchResponse { + query?: { + original?: string; + altered?: string; + show_strict_warning?: boolean; + more_results_available?: boolean; + spellcheck_off?: boolean; + }; + web?: { + results?: BraveWebResult[]; + }; + summarizer?: { + key?: string; + }; + [key: string]: unknown; +} + +interface CachedSearchResult { + results: SearchResultFormatted[]; + summarizerKey?: string; + queryCorrected?: boolean; + originalQuery?: string; + correctedQuery?: string; + moreResultsAvailable?: boolean; +} + +/** Structured details returned from the search tool. */ +interface SearchDetails { + query: string; + effectiveQuery: string; + results: SearchResultFormatted[]; + count: number; + cached: boolean; + freshness: string; + hasSummary: boolean; + latencyMs?: number; + rateLimit?: RateLimitInfo; + queryCorrected?: boolean; + originalQuery?: string; + correctedQuery?: string; + moreResultsAvailable?: boolean; + errorKind?: string; + error?: string; + retryAfterMs?: number; +} + +// ============================================================================= +// Caches +// ============================================================================= + +// Search results: max 100 entries, 10-minute TTL +const searchCache = new LRUTTLCache<CachedSearchResult>({ max: 100, ttlMs: 600_000 }); +searchCache.startPurgeInterval(60_000); + +// Summarizer responses: max 50 entries, 15-minute TTL +const summarizerCache = new LRUTTLCache<string>({ max: 50, ttlMs: 900_000 }); + +// ============================================================================= +// Brave API helpers +// ============================================================================= + +function getBraveApiKey(): string { + return process.env.BRAVE_API_KEY || ""; +} + +function braveHeaders(): Record<string, string> { + return { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": getBraveApiKey(), + }; +} + +/** + * Normalize a Brave result into our formatted result type. + */ +function normalizeBraveResult(r: BraveWebResult): SearchResultFormatted { + return { + title: r.title || "(untitled)", + url: r.url, + description: r.description || "", + age: r.age || r.page_age || undefined, + extra_snippets: r.extra_snippets || undefined, + }; +} + +/** + * Deduplicate results by URL (first occurrence wins). + */ +function deduplicateResults(results: SearchResultFormatted[]): SearchResultFormatted[] { + const seen = new Map<string, SearchResultFormatted>(); + for (const result of results) { + const key = toDedupeKey(result.url); + if (key !== null && !seen.has(key)) { + seen.set(key, result); + } + } + return Array.from(seen.values()); +} + +/** + * Fetch AI summary from Brave Summarizer API (best-effort, free). + */ +async function fetchSummary( + summarizerKey: string, + signal?: AbortSignal +): Promise<string | null> { + const cached = summarizerCache.get(summarizerKey); + if (cached !== undefined) return cached; + + try { + const url = `https://api.search.brave.com/res/v1/summarizer/search?key=${encodeURIComponent(summarizerKey)}&entity_info=false`; + const response = await fetchWithRetry(url, { + method: "GET", + headers: braveHeaders(), + signal, + }, 1); + + const data: BraveSummarizerResponse = await response.json(); + + let summaryText = ""; + if (data.summary && Array.isArray(data.summary)) { + summaryText = data.summary + .filter((s) => s.type === "token" || s.type === "text") + .map((s) => s.data) + .join(""); + } + + if (summaryText) { + summarizerCache.set(summarizerKey, summaryText); + return summaryText; + } + return null; + } catch { + return null; + } +} + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export function registerSearchTool(pi: ExtensionAPI) { + pi.registerTool({ + name: "search-the-web", + label: "Web Search", + description: + "Search the web using Brave Search API. Returns top results with titles, URLs, descriptions, " + + "extra contextual snippets, result ages, and optional AI summary. " + + "Supports freshness filtering, domain filtering, and auto-detects recency-sensitive queries.", + promptSnippet: "Search the web for information", + promptGuidelines: [ + "Use this tool when the user asks about current events, facts, or external knowledge not in the codebase.", + "Always provide the search query to the user in your response.", + "Limit to 3-5 results unless more context is needed.", + "Use freshness='week' or 'month' for queries about recent events, releases, or updates.", + "Use the fetch_page tool to read the full content of promising URLs from search results.", + ], + parameters: Type.Object({ + query: Type.String({ description: "Search query (e.g., 'latest AI news')" }), + count: Type.Optional( + Type.Number({ minimum: 1, maximum: 10, default: 5, description: "Number of results to return (default: 5)" }) + ), + freshness: Type.Optional( + StringEnum(["auto", "day", "week", "month", "year"] as const, { + description: + "Filter by recency. 'auto' (default) detects from query. 'day'=past 24h, 'week'=past 7d, 'month'=past 30d, 'year'=past 365d.", + }) + ), + domain: Type.Optional( + Type.String({ + description: "Limit results to a specific domain (e.g., 'stackoverflow.com', 'github.com')", + }) + ), + summary: Type.Optional( + Type.Boolean({ + description: "Request an AI-generated summary of the search results (default: false). Adds latency but provides a concise answer.", + default: false, + }) + ), + }), + + async execute(toolCallId, params, signal, onUpdate, ctx) { + if (signal?.aborted) { + return { content: [{ type: "text", text: "Search cancelled." }] }; + } + + const apiKey = getBraveApiKey(); + if (!apiKey) { + return { + content: [{ type: "text", text: "Web search unavailable: BRAVE_API_KEY is not set. Use secure_env_collect to set BRAVE_API_KEY." }], + isError: true, + details: { errorKind: "auth_error", error: "BRAVE_API_KEY not set" } satisfies Partial<SearchDetails>, + }; + } + + const count = params.count ?? 5; + const wantSummary = params.summary ?? false; + + // ------------------------------------------------------------------ + // Resolve freshness + // ------------------------------------------------------------------ + let freshness: string | null = null; + if (params.freshness && params.freshness !== "auto") { + const freshnessMap: Record<string, string> = { + day: "pd", week: "pw", month: "pm", year: "py", + }; + freshness = freshnessMap[params.freshness] || null; + } else { + freshness = detectFreshness(params.query); + } + + // ------------------------------------------------------------------ + // Handle domain filter + // ------------------------------------------------------------------ + let effectiveQuery = params.query; + if (params.domain) { + if (!effectiveQuery.toLowerCase().includes("site:")) { + effectiveQuery = `site:${params.domain} ${effectiveQuery}`; + } + } + + // ------------------------------------------------------------------ + // Cache lookup + // ------------------------------------------------------------------ + const cacheKey = normalizeQuery(effectiveQuery) + `|f:${freshness || ""}|s:${wantSummary}`; + const cached = searchCache.get(cacheKey); + + if (cached) { + const limited = cached.results.slice(0, count); + + let summaryText: string | undefined; + if (wantSummary && cached.summarizerKey) { + summaryText = (await fetchSummary(cached.summarizerKey, signal)) ?? undefined; + } + + const formatOpts: FormatSearchOptions = { + cached: true, + summary: summaryText, + queryCorrected: cached.queryCorrected, + originalQuery: cached.originalQuery, + correctedQuery: cached.correctedQuery, + moreResultsAvailable: cached.moreResultsAvailable, + }; + + const output = formatSearchResults(params.query, limited, formatOpts); + + const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let content = truncation.content; + if (truncation.truncated) { + const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" }); + content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`; + } + + const details: SearchDetails = { + query: params.query, + effectiveQuery, + results: limited, + count: limited.length, + cached: true, + freshness: freshness || "none", + hasSummary: !!summaryText, + queryCorrected: cached.queryCorrected, + originalQuery: cached.originalQuery, + correctedQuery: cached.correctedQuery, + moreResultsAvailable: cached.moreResultsAvailable, + }; + + return { content: [{ type: "text", text: content }], details }; + } + + onUpdate?.({ content: [{ type: "text", text: `Searching for "${params.query}"...` }] }); + + try { + // ------------------------------------------------------------------ + // Build Brave API request + // ------------------------------------------------------------------ + const url = new URL("https://api.search.brave.com/res/v1/web/search"); + url.searchParams.append("q", effectiveQuery); + url.searchParams.append("count", "10"); // Extra for dedup headroom + url.searchParams.append("extra_snippets", "true"); + url.searchParams.append("text_decorations", "false"); + + if (freshness) { + url.searchParams.append("freshness", freshness); + } + if (wantSummary) { + url.searchParams.append("summary", "1"); + } + + // ------------------------------------------------------------------ + // Execute with timing + // ------------------------------------------------------------------ + let timed; + try { + timed = await fetchWithRetryTimed(url.toString(), { + method: "GET", + headers: braveHeaders(), + signal, + }, 2); + } catch (fetchErr) { + const classified = classifyError(fetchErr); + return { + content: [{ type: "text", text: `Search failed: ${classified.message}` }], + details: { + errorKind: classified.kind, + error: classified.message, + retryAfterMs: classified.retryAfterMs, + query: params.query, + } satisfies Partial<SearchDetails>, + isError: true, + }; + } + + const data: BraveSearchResponse = await timed.response.json(); + const rawResults: BraveWebResult[] = data.web?.results ?? []; + const summarizerKey: string | undefined = data.summarizer?.key; + + // ------------------------------------------------------------------ + // Extract spellcheck/correction info + // ------------------------------------------------------------------ + const queryInfo = data.query; + const queryCorrected = !!(queryInfo?.altered && queryInfo.altered !== queryInfo.original); + const originalQuery = queryCorrected ? (queryInfo?.original ?? params.query) : undefined; + const correctedQuery = queryCorrected ? queryInfo?.altered : undefined; + const moreResultsAvailable = queryInfo?.more_results_available ?? false; + + // ------------------------------------------------------------------ + // Normalize, deduplicate, cache + // ------------------------------------------------------------------ + const normalized = rawResults.map(normalizeBraveResult); + const deduplicated = deduplicateResults(normalized); + + searchCache.set(cacheKey, { + results: deduplicated, + summarizerKey, + queryCorrected, + originalQuery, + correctedQuery, + moreResultsAvailable, + }); + + const results = deduplicated.slice(0, count); + + // ------------------------------------------------------------------ + // Optionally fetch AI summary (best-effort) + // ------------------------------------------------------------------ + let summaryText: string | undefined; + if (wantSummary && summarizerKey) { + summaryText = (await fetchSummary(summarizerKey, signal)) ?? undefined; + } + + // ------------------------------------------------------------------ + // Format output + // ------------------------------------------------------------------ + const formatOpts: FormatSearchOptions = { + summary: summaryText, + queryCorrected, + originalQuery, + correctedQuery, + moreResultsAvailable, + }; + + const output = formatSearchResults(params.query, results, formatOpts); + + const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let content = truncation.content; + + if (truncation.truncated) { + const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" }); + content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`; + } + + const details: SearchDetails = { + query: params.query, + effectiveQuery, + results, + count: results.length, + cached: false, + freshness: freshness || "none", + hasSummary: !!summaryText, + latencyMs: timed.latencyMs, + rateLimit: timed.rateLimit, + queryCorrected, + originalQuery, + correctedQuery, + moreResultsAvailable, + }; + + return { content: [{ type: "text", text: content }], details }; + } catch (error) { + const classified = classifyError(error); + return { + content: [{ type: "text", text: `Search failed: ${classified.message}` }], + details: { + errorKind: classified.kind, + error: classified.message, + query: params.query, + } satisfies Partial<SearchDetails>, + isError: true, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("search-the-web ")); + text += theme.fg("muted", `"${args.query}"`); + + const meta: string[] = []; + if (args.count && args.count !== 5) meta.push(`${args.count} results`); + if (args.freshness && args.freshness !== "auto") meta.push(`freshness:${args.freshness}`); + if (args.domain) meta.push(`site:${args.domain}`); + if (args.summary) meta.push("+ summary"); + if (meta.length > 0) { + text += " " + theme.fg("dim", `(${meta.join(", ")})`); + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as SearchDetails | undefined; + if (details?.errorKind || details?.error) { + const kindTag = details.errorKind ? theme.fg("dim", ` [${details.errorKind}]`) : ""; + return new Text(theme.fg("error", `✗ ${details.error ?? "Search failed"}`) + kindTag, 0, 0); + } + + const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : ""; + const freshTag = details?.freshness && details.freshness !== "none" + ? theme.fg("dim", ` [${details.freshness}]`) + : ""; + const summaryTag = details?.hasSummary ? theme.fg("dim", " [+summary]") : ""; + const latencyTag = details?.latencyMs ? theme.fg("dim", ` ${details.latencyMs}ms`) : ""; + const correctedTag = details?.queryCorrected + ? theme.fg("warning", ` [corrected→"${details.correctedQuery}"]`) + : ""; + + let text = theme.fg("success", `✓ ${details?.count ?? 0} results for "${details?.query}"`) + + cacheTag + freshTag + summaryTag + latencyTag + correctedTag; + + if (expanded && details?.results) { + text += "\n\n"; + for (const r of details.results.slice(0, 3)) { + const age = r.age ? theme.fg("dim", ` (${r.age})`) : ""; + text += `${theme.bold(r.title)}${age}\n${r.url}\n${r.description}\n\n`; + } + if (details.results.length > 3) { + text += theme.fg("dim", `... and ${details.results.length - 3} more`); + } + } + + return new Text(text, 0, 0); + }, + }); +} diff --git a/src/resources/extensions/search-the-web/url-utils.ts b/src/resources/extensions/search-the-web/url-utils.ts new file mode 100644 index 000000000..eda74be4f --- /dev/null +++ b/src/resources/extensions/search-the-web/url-utils.ts @@ -0,0 +1,91 @@ +/** + * URL normalization and query utilities. + */ + +/** Normalize a search query into a stable cache key. */ +export function normalizeQuery(query: string): string { + return query.trim().toLowerCase().replace(/\s+/g, " ").normalize("NFC"); +} + +/** + * Canonical URL for deduplication. + * Strips fragment, tracking params, lowercases hostname, sorts query params, + * strips trailing "/" on root paths. + */ +export function toDedupeKey(url: string): string | null { + try { + const parsed = new URL(url); + parsed.hostname = parsed.hostname.toLowerCase(); + parsed.hash = ""; + + const TRACKING_PARAMS = new Set(["fbclid", "gclid"]); + const toDelete: string[] = []; + for (const key of parsed.searchParams.keys()) { + if (key.startsWith("utm_") || TRACKING_PARAMS.has(key)) { + toDelete.push(key); + } + } + for (const key of toDelete) parsed.searchParams.delete(key); + parsed.searchParams.sort(); + + let canonical = parsed.toString(); + if (parsed.pathname === "/" && !parsed.search) { + canonical = canonical.replace(/\/$/, ""); + } + return canonical; + } catch { + return null; + } +} + +/** + * Extract a clean domain from a URL for display. + * "https://docs.python.org/3/library/asyncio.html" → "docs.python.org" + */ +export function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * Detect if a query likely wants fresh/recent results. + * Returns a suggested Brave freshness parameter or null. + */ +export function detectFreshness(query: string): string | null { + const q = query.toLowerCase(); + + // Explicit year references for current/recent years + const currentYear = new Date().getFullYear(); + for (let y = currentYear; y >= currentYear - 1; y--) { + if (q.includes(String(y))) return "py"; // past year + } + + // Recency keywords + const recentPatterns = [ + /\b(latest|newest|recent|new|just released|just launched)\b/, + /\b(today|yesterday|this week|this month)\b/, + /\b(breaking|update|announcement|release notes?)\b/, + /\b(what('?s| is) new)\b/, + ]; + for (const pattern of recentPatterns) { + if (pattern.test(q)) return "pm"; // past month + } + + return null; +} + +/** + * Detect if a query targets specific domains. + * Returns extracted domains or null. + */ +export function detectDomainHints(query: string): string[] | null { + // Match "site:example.com" patterns + const siteMatches = query.match(/site:(\S+)/gi); + if (siteMatches) { + return siteMatches.map((m) => m.replace(/^site:/i, "")); + } + return null; +} diff --git a/src/resources/extensions/shared/interview-ui.ts b/src/resources/extensions/shared/interview-ui.ts new file mode 100644 index 000000000..e7649f181 --- /dev/null +++ b/src/resources/extensions/shared/interview-ui.ts @@ -0,0 +1,613 @@ +/** + * Shared interview round UI widget. + * + * Used by /interview-me and /gsd-new-project. + * + * Renders a paged, keyboard-driven question UI with: + * - Single-select (radio) questions + * - Multi-select (checkbox) questions via allowMultiple: true + * - Optional notes field (Tab to open) + * - Review screen before submitting — shows all answers, single submit button + * - Exit confirmation on Esc — "End interview?" with keep-going as default + * - focusNotes dimming: checked/committed items stay visible, others dim + * + * Navigation: + * ←/→ move between questions + * ↑/↓ move cursor within a question's options + * Enter/Space commit selection and advance + * Tab open/close notes field + * Esc exit confirmation overlay (keep-going is default) + * + * On last question, Enter advances to a review screen instead of submitting directly. + * From the review screen: + * ← back to last question + * Enter / → submit all answers + * Esc exit confirmation + */ + +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { type Theme } from "@mariozechner/pi-coding-agent"; +import { + Editor, + Key, + matchesKey, + truncateToWidth, + type TUI, +} from "@mariozechner/pi-tui"; +import { makeUI, INDENT } from "./ui.js"; + +// ─── Exported types ─────────────────────────────────────────────────────────── + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + id: string; + header: string; + question: string; + options: QuestionOption[]; + /** If true, user can toggle multiple options with SPACE, confirm with ENTER */ + allowMultiple?: boolean; +} + +export interface RoundResult { + /** Always false — end is handled by showWrapUpScreen, not per-question */ + endInterview: false; + answers: Record<string, { selected: string | string[]; notes: string }>; +} + +export interface WrapUpResult { + /** true = wrap up and write file, false = keep going */ + satisfied: boolean; +} + +// ─── Options ───────────────────────────────────────────────────────────────── + +export interface InterviewRoundOptions { + /** + * Optional progress string shown in the header — e.g. "Batch 2/3 • 12 asked". + * Caller formats it however makes sense for their context. + * If omitted, no progress line is shown. + */ + progress?: string; + /** + * Label for the review screen header. Defaults to "Review your answers". + */ + reviewHeadline?: string; + /** + * Label for the Esc-confirm overlay header. Defaults to "End interview?". + */ + exitHeadline?: string; + /** + * Text for the "exit" hint shown in the review screen footer and exit confirm overlay. + * Defaults to "end interview". + */ + exitLabel?: string; +} + +export interface WrapUpOptions { + /** + * Optional progress string shown below the headline — e.g. "12 questions answered so far". + * Caller formats it however makes sense for their context. + * If omitted, no progress line is shown. + */ + progress?: string; + /** Caller-specific text for the wrap-up screen headline */ + headline: string; + /** Label for the "keep going" option (shown first — safe default) */ + keepGoingLabel: string; + /** Label for the "I'm satisfied" option (shown second) */ + satisfiedLabel: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const OTHER_OPTION_LABEL = "None of the above"; +const OTHER_OPTION_DESCRIPTION = "Optionally, add details in notes below."; + +// ─── Wrap-up screen ─────────────────────────────────────────────────────────── + +export async function showWrapUpScreen( + opts: WrapUpOptions, + ctx: ExtensionCommandContext, +): Promise<WrapUpResult> { + return ctx.ui.custom<WrapUpResult>((tui: TUI, theme: Theme, _kb, done) => { + // 0 = "Keep going", 1 = "I'm satisfied" — default to satisfied (1) + let cursorIdx = 1; + let cachedLines: string[] | undefined; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { cursorIdx = 1; refresh(); return; } + if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { cursorIdx = 0; refresh(); return; } + if (data === "1") { done({ satisfied: true }); return; } + if (data === "2") { done({ satisfied: false }); return; } + // Esc = "keep going" (the safe/non-destructive default) + if (matchesKey(data, Key.escape)) { done({ satisfied: false }); return; } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + done({ satisfied: cursorIdx === 1 }); + return; + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push(ui.bar(), ui.blank(), ui.header(` ${opts.headline}`), ui.blank()); + if (opts.progress) push(ui.meta(` ${opts.progress}`), ui.blank()); + + if (cursorIdx === 1) { + push(ui.actionSelected(1, opts.satisfiedLabel, "Wrap up now and generate the output.")); + } else { + push(ui.actionUnselected(1, opts.satisfiedLabel, "Wrap up now and generate the output.")); + } + push(ui.blank()); + if (cursorIdx === 0) { + push(ui.actionSelected(2, opts.keepGoingLabel, "Continue with another batch of questions.")); + } else { + push(ui.actionUnselected(2, opts.keepGoingLabel, "Continue with another batch of questions.")); + } + push( + ui.blank(), + ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]), + ui.bar(), + ); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { cachedLines = undefined; }, + handleInput, + }; + }); +} + +// ─── Interview round ────────────────────────────────────────────────────────── + +export async function showInterviewRound( + questions: Question[], + opts: InterviewRoundOptions, + ctx: ExtensionCommandContext, +): Promise<RoundResult> { + return ctx.ui.custom<RoundResult>((tui: TUI, theme: Theme, _kb, done) => { + + interface QuestionState { + cursorIndex: number; + committedIndex: number | null; + checkedIndices: Set<number>; + notes: string; + notesVisible: boolean; + } + + const states: QuestionState[] = questions.map(() => ({ + cursorIndex: 0, + committedIndex: null, + checkedIndices: new Set(), + notes: "", + notesVisible: false, + })); + + const isMultiQuestion = questions.length > 1; + let currentIdx = 0; + let focusNotes = false; + let showingReview = false; + let showingExitConfirm = false; + let exitCursor = 0; // 0 = keep going (default), 1 = end interview + let cachedLines: string[] | undefined; + + // Editor is created once; editorTheme comes from the design system + const editorRef = { current: null as Editor | null }; + + function getEditor(): Editor { + if (!editorRef.current) { + editorRef.current = new Editor(tui, makeUI(theme, 80).editorTheme); + } + return editorRef.current; + } + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function isMultiSelect(qIdx: number): boolean { + return !!questions[qIdx].allowMultiple; + } + + function totalOpts(qIdx: number): number { + return questions[qIdx].options.length + 1; + } + + function noneOrDoneIdx(qIdx: number): number { + return questions[qIdx].options.length; + } + + function saveEditorToState() { + states[currentIdx].notes = getEditor().getText().trim(); + } + + function loadStateToEditor() { + getEditor().setText(states[currentIdx].notes); + } + + function isQuestionAnswered(idx: number): boolean { + if (isMultiSelect(idx)) return states[idx].checkedIndices.size > 0; + return states[idx].committedIndex !== null; + } + + function allAnswered(): boolean { + return questions.every((_, i) => isQuestionAnswered(i)); + } + + function switchQuestion(newIdx: number) { + if (newIdx === currentIdx) return; + saveEditorToState(); + currentIdx = newIdx; + loadStateToEditor(); + focusNotes = states[currentIdx].notesVisible && states[currentIdx].notes.length > 0; + refresh(); + } + + function buildResult(): RoundResult { + const answers: Record<string, { selected: string | string[]; notes: string }> = {}; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const st = states[i]; + const notes = st.notes.trim(); + + if (isMultiSelect(i)) { + const sorted = Array.from(st.checkedIndices).sort((a, b) => a - b); + const selected = sorted.map((idx) => q.options[idx].label); + if (selected.length > 0 || notes) answers[q.id] = { selected, notes }; + } else { + if (st.committedIndex === null && !notes) continue; + let selected = OTHER_OPTION_LABEL; + if (st.committedIndex !== null) { + const idx = st.committedIndex; + if (idx < q.options.length) selected = q.options[idx].label; + else if (idx === noneOrDoneIdx(i)) selected = OTHER_OPTION_LABEL; + } + answers[q.id] = { selected, notes }; + } + } + return { endInterview: false, answers }; + } + + function submit() { + saveEditorToState(); + done(buildResult()); + } + + function goNextOrSubmit() { + if (!isMultiSelect(currentIdx)) { + states[currentIdx].committedIndex = states[currentIdx].cursorIndex; + } + + if (isMultiQuestion && currentIdx < questions.length - 1) { + let next = currentIdx + 1; + for (let i = 0; i < questions.length; i++) { + const candidate = (currentIdx + 1 + i) % questions.length; + if (!isQuestionAnswered(candidate)) { next = candidate; break; } + } + switchQuestion(next); + } else if (allAnswered()) { + saveEditorToState(); + showingReview = true; + refresh(); + } + } + + // ── Input handler ──────────────────────────────────────────────────── + + function handleInput(data: string) { + // ── Exit confirmation overlay ────────────────────────────────── + if (showingExitConfirm) { + if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { exitCursor = 0; refresh(); return; } + if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { exitCursor = 1; refresh(); return; } + if (data === "1") { showingExitConfirm = false; refresh(); return; } + if (data === "2") { done({ endInterview: false, answers: {} }); return; } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + if (exitCursor === 0) { showingExitConfirm = false; refresh(); } + else { done({ endInterview: false, answers: {} }); } + return; + } + if (matchesKey(data, Key.escape)) { showingExitConfirm = false; refresh(); return; } + return; + } + + // ── Review screen ──────────────────────────────────────────── + if (showingReview) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) { + showingReview = false; + switchQuestion(questions.length - 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.right) || matchesKey(data, Key.space)) { + submit(); + return; + } + return; + } + + const st = states[currentIdx]; + const optCount = totalOpts(currentIdx); + const multiSel = isMultiSelect(currentIdx); + + // ── Esc → exit confirmation ────────────────────────────────── + if (matchesKey(data, Key.escape)) { + if (focusNotes) { + saveEditorToState(); + focusNotes = false; + st.notesVisible = st.notes.length > 0; + refresh(); + } else { + showingExitConfirm = true; + exitCursor = 0; + refresh(); + } + return; + } + + // ── Notes mode ─────────────────────────────────────────────── + if (focusNotes) { + if (matchesKey(data, Key.tab)) { + saveEditorToState(); + focusNotes = false; + st.notesVisible = st.notes.length > 0; + refresh(); + return; + } + if (matchesKey(data, Key.enter)) { + saveEditorToState(); + focusNotes = false; + if (!multiSel && st.committedIndex === null) st.committedIndex = noneOrDoneIdx(currentIdx); + goNextOrSubmit(); + return; + } + getEditor().handleInput(data); + refresh(); + return; + } + + // ── Multi-question navigation ──────────────────────────────── + if (isMultiQuestion) { + if (matchesKey(data, Key.left)) { switchQuestion((currentIdx - 1 + questions.length) % questions.length); return; } + if (matchesKey(data, Key.right)) { switchQuestion((currentIdx + 1) % questions.length); return; } + } + + // ── Cursor navigation ──────────────────────────────────────── + if (matchesKey(data, Key.up)) { st.cursorIndex = (st.cursorIndex - 1 + optCount) % optCount; refresh(); return; } + if (matchesKey(data, Key.down)) { st.cursorIndex = (st.cursorIndex + 1) % optCount; refresh(); return; } + + if (multiSel) { + const doneI = noneOrDoneIdx(currentIdx); + if (matchesKey(data, Key.space)) { + if (st.cursorIndex < doneI) { + if (st.checkedIndices.has(st.cursorIndex)) st.checkedIndices.delete(st.cursorIndex); + else st.checkedIndices.add(st.cursorIndex); + refresh(); + } + return; + } + if (matchesKey(data, Key.enter)) { goNextOrSubmit(); return; } + if (matchesKey(data, Key.tab)) { st.notesVisible = true; focusNotes = true; loadStateToEditor(); refresh(); return; } + } else { + if (data.length === 1 && data >= "1" && data <= "9") { + const idx = parseInt(data, 10) - 1; + if (idx < optCount) { st.cursorIndex = idx; st.committedIndex = idx; goNextOrSubmit(); return; } + } + if (matchesKey(data, Key.space)) { st.committedIndex = st.cursorIndex; refresh(); return; } + if (matchesKey(data, Key.tab)) { st.notesVisible = true; focusNotes = true; loadStateToEditor(); refresh(); return; } + if (matchesKey(data, Key.enter)) { goNextOrSubmit(); return; } + } + } + + // ── Review screen ──────────────────────────────────────────────── + + function renderReviewScreen(width: number): string[] { + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push(ui.bar(), ui.blank(), ui.header(` ${opts.reviewHeadline ?? "Review your answers"}`), ui.blank()); + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const st = states[i]; + + push(ui.subtitle(` ${q.question}`)); + + if (isMultiSelect(i)) { + const selected = Array.from(st.checkedIndices).sort((a, b) => a - b).map((idx) => q.options[idx].label); + for (const label of selected) push(ui.answer(` ${INDENT.cursor}${label}`)); + } else { + let label = OTHER_OPTION_LABEL; + if (st.committedIndex !== null && st.committedIndex < q.options.length) { + label = q.options[st.committedIndex].label; + } + push(ui.answer(` ${INDENT.cursor}${label}`)); + } + + if (st.notes) push(ui.note(`${INDENT.note}note: ${st.notes}`)); + push(ui.blank()); + } + + push( + ui.actionSelected(0, "Submit answers"), + ui.blank(), + ui.hints(["← to go back and edit", "enter to submit", `esc to ${opts.exitLabel ?? "end interview"}`]), + ui.bar(), + ); + + return lines; + } + + // ── Exit confirm screen ────────────────────────────────────────── + + function renderExitConfirm(width: number): string[] { + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push( + ui.bar(), + ui.blank(), + ui.header(` ${opts.exitHeadline ?? "End interview?"}`), + ui.blank(), + ui.subtitle(" Answers from this batch won't be saved."), + ui.blank(), + ); + + const keepGoingLabel = "Keep going"; + const exitActionLabel = opts.exitLabel + ? opts.exitLabel.charAt(0).toUpperCase() + opts.exitLabel.slice(1) + : "End interview"; + if (exitCursor === 0) { + push(ui.actionSelected(1, keepGoingLabel, "Return and keep going.")); + } else { + push(ui.actionUnselected(1, keepGoingLabel, "Return and keep going.")); + } + push(ui.blank()); + if (exitCursor === 1) { + push(ui.actionSelected(2, exitActionLabel, "Exit and discard this batch of answers.")); + } else { + push(ui.actionUnselected(2, exitActionLabel, "Exit and discard this batch of answers.")); + } + push( + ui.blank(), + ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]), + ui.bar(), + ); + + return lines; + } + + // ── Main render ────────────────────────────────────────────────── + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + if (showingExitConfirm) { cachedLines = renderExitConfirm(width); return cachedLines; } + if (showingReview) { cachedLines = renderReviewScreen(width); return cachedLines; } + + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + const q = questions[currentIdx]; + const st = states[currentIdx]; + const multiSel = isMultiSelect(currentIdx); + + push(ui.bar()); + + // ── Progress header ──────────────────────────────────────────── + if (isMultiQuestion) { + const unanswered = questions.filter((_, i) => !isQuestionAnswered(i)).length; + const answeredSet = new Set(questions.map((_, i) => i).filter(i => isQuestionAnswered(i))); + push(ui.questionTabs(questions.map(q => q.header), currentIdx, answeredSet)); + push(ui.blank()); + const progressParts = [ + opts.progress, + `Question ${currentIdx + 1}/${questions.length}`, + unanswered > 0 ? `${unanswered} unanswered` : null, + ].filter(Boolean).join(" • "); + if (progressParts) push(ui.meta(` ${progressParts}`)); + push(ui.blank()); + } else { + if (opts.progress) push(ui.meta(` ${opts.progress}`), ui.blank()); + } + + // ── Question text ────────────────────────────────────────────── + push(ui.question(` ${q.question}`)); + if (multiSel) push(ui.meta(" (Select all that apply)")); + push(ui.blank()); + + // ── Options ─────────────────────────────────────────────────── + for (let i = 0; i < q.options.length; i++) { + const opt = q.options[i]; + const isCursor = i === st.cursorIndex; + + if (multiSel) { + const isChecked = st.checkedIndices.has(i); + if (isCursor && !focusNotes) push(ui.checkboxSelected(opt.label, opt.description, isChecked)); + else push(ui.checkboxUnselected(opt.label, opt.description, isChecked, focusNotes)); + } else { + const isCommitted = i === st.committedIndex; + if (isCursor && !focusNotes) { + push(ui.optionSelected(i + 1, opt.label, opt.description, isCommitted)); + } else { + push(ui.optionUnselected(i + 1, opt.label, opt.description, { isCommitted, isFocusDimmed: focusNotes })); + } + } + } + + // ── None / Done slot ─────────────────────────────────────────── + const ndIdx = noneOrDoneIdx(currentIdx); + const ndCursor = ndIdx === st.cursorIndex; + + if (multiSel) { + push(ui.blank()); + if (ndCursor && !focusNotes) push(ui.doneSelected()); + else push(ui.doneUnselected()); + } else { + const ndCommitted = ndIdx === st.committedIndex; + if (ndCursor && !focusNotes) { + push(ui.slotSelected(OTHER_OPTION_LABEL, OTHER_OPTION_DESCRIPTION, ndCommitted)); + } else { + push(ui.slotUnselected(OTHER_OPTION_LABEL, OTHER_OPTION_DESCRIPTION, { isCommitted: ndCommitted, isFocusDimmed: focusNotes })); + } + } + + // ── Notes area ───────────────────────────────────────────────── + if (st.notesVisible || focusNotes) { + push(ui.blank(), ui.notesLabel(focusNotes)); + if (focusNotes) { + for (const line of getEditor().render(width - 2)) lines.push(truncateToWidth(` ${line}`, width)); + } else if (st.notes) { + push(ui.notesText(st.notes)); + } + } + + // ── Footer hints ─────────────────────────────────────────────── + push(ui.blank()); + const isLast = !isMultiQuestion || currentIdx === questions.length - 1; + const hints: string[] = []; + if (focusNotes) { + hints.push("enter to confirm"); + hints.push("tab or esc to close notes"); + } else if (multiSel) { + hints.push("space to toggle"); + if (isMultiQuestion) hints.push("←/→ navigate questions"); + hints.push("tab to add notes"); + hints.push(isLast && allAnswered() ? "enter to review" : "enter to next"); + } else { + if (st.committedIndex !== null || !isMultiQuestion) hints.push("tab to add notes"); + if (isMultiQuestion) hints.push("←/→ navigate"); + hints.push(isLast && allAnswered() ? "enter to review" : "enter to next"); + } + hints.push("esc to exit"); + push(ui.hints(hints), ui.bar()); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { cachedLines = undefined; }, + handleInput, + }; + }); +} diff --git a/src/resources/extensions/shared/next-action-ui.ts b/src/resources/extensions/shared/next-action-ui.ts new file mode 100644 index 000000000..1741333a2 --- /dev/null +++ b/src/resources/extensions/shared/next-action-ui.ts @@ -0,0 +1,197 @@ +/** + * Shared next-action prompt for GSD extensions. + * + * Renders a consistent "step complete" UI at the end of every GSD stage: + * + * ───────────────────────────────────────── + * ✓ Phase 1 research complete + * + * [caller summary lines] + * + * [optional extra content block] + * + * Files written: + * .gsd/phases/01-foo/01-RESEARCH.md + * + * › 1. Plan phase 1 ← recommended, pre-selected + * Create PLAN.md files for execution + * + * 2. Not yet + * Run /gsd-plan-phase 1 when ready. + * ───────────────────────────────────────── + * + * Usage: + * + * const choice = await showNextAction(ctx, { + * title: "Phase 1 research complete", + * summary: ["6 libraries evaluated", "Stack: Phaser 3 + TypeScript"], + * files: ["/abs/path/to/01-RESEARCH.md"], + * extra: ["Wave 1: 01-01, 01-02 (parallel)", "Wave 2: 01-03"], + * actions: [ + * { id: "plan", label: "Plan phase 1", description: "Create PLAN.md files for execution", recommended: true }, + * { id: "later", label: "Discuss first", description: "Capture constraints before planning" }, + * ], + * notYetMessage: "Run /gsd-plan-phase 1 when ready.", + * }); + * + * // choice is one of the action ids, or "not_yet" + * if (choice === "plan") { ... } + * + * "Not yet" is always appended automatically as the last option. + * Pressing Escape also resolves as "not_yet". + */ + +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { type Theme } from "@mariozechner/pi-coding-agent"; +import { Key, matchesKey, type TUI } from "@mariozechner/pi-tui"; +import { makeUI } from "./ui.js"; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export interface NextAction { + /** Unique id returned when this action is chosen. */ + id: string; + /** Short label shown in the list (e.g. "Plan phase 1"). */ + label: string; + /** One-line description shown below the label. */ + description: string; + /** Pre-selects this item and renders it with a (recommended) marker. At most one. */ + recommended?: boolean; +} + +export interface NextActionOptions { + /** Bold heading after the ✓ — e.g. "Phase 1 research complete". */ + title: string; + /** + * Stage-specific narrative lines rendered below the title. + * Keep these short and informative. + */ + summary?: string[]; + /** + * Absolute paths to files that were written this step. + * Displayed as relative paths from cwd when possible. + */ + files?: string[]; + /** + * Optional extra content rendered between the file list and the actions. + * Each string is one display line — already formatted by the caller. + */ + extra?: string[]; + /** The action choices. "Not yet" is always appended automatically. */ + actions: NextAction[]; + /** + * Message shown in the "Not yet" description line. + * e.g. "Run /gsd-plan-phase 1 when ready." + */ + notYetMessage?: string; + /** + * Current working directory — used to make file paths relative. + * Defaults to process.cwd(). + */ + cwd?: string; +} + +/** + * Show the next-action prompt and return the chosen action id, or "not_yet". + */ +export async function showNextAction( + ctx: ExtensionCommandContext, + opts: NextActionOptions, +): Promise<string> { + const cwd = opts.cwd ?? process.cwd(); + const notYetMessage = opts.notYetMessage ?? "Continue when ready."; + + const allActions: NextAction[] = [ + ...opts.actions, + { id: "not_yet", label: "Not yet", description: notYetMessage }, + ]; + + const recommendedIdx = allActions.findIndex((a) => a.recommended); + const defaultIdx = recommendedIdx >= 0 ? recommendedIdx : 0; + + const relativeFiles = (opts.files ?? []).map((f) => { + try { + const rel = f.startsWith(cwd) ? f.slice(cwd.length).replace(/^\//, "") : f; + return rel || f; + } catch { + return f; + } + }); + + return ctx.ui.custom<string>((_tui: TUI, theme: Theme, _kb, done) => { + let cursorIdx = defaultIdx; + let cachedLines: string[] | undefined; + + function refresh() { cachedLines = undefined; _tui.requestRender(); } + + function handleInput(data: string) { + if (matchesKey(data, Key.up)) { cursorIdx = Math.max(0, cursorIdx - 1); refresh(); return; } + if (matchesKey(data, Key.down)) { cursorIdx = Math.min(allActions.length - 1, cursorIdx + 1); refresh(); return; } + const num = parseInt(data, 10); + if (!isNaN(num) && num >= 1 && num <= allActions.length) { done(allActions[num - 1].id); return; } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { done(allActions[cursorIdx].id); return; } + if (matchesKey(data, Key.escape)) { done("not_yet"); return; } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + // ── Header — uses success colour to signal completion ──────────── + // Note: next-action intentionally uses "success" for its bar/title + // to distinguish it from regular accent-coloured screens. + push(ui.bar()); + push(ui.blank()); + push(ui.header(` ✓ ${opts.title}`)); + + // ── Summary ────────────────────────────────────────────────────── + if (opts.summary && opts.summary.length > 0) { + push(ui.blank()); + for (const line of opts.summary) push(ui.subtitle(` ${line}`)); + } + + // ── Files written ───────────────────────────────────────────────── + if (relativeFiles.length > 0) { + push(ui.blank()); + push(ui.meta(" Files written:")); + for (const f of relativeFiles) push(ui.meta(` ${f}`)); + } + + // ── Extra content ───────────────────────────────────────────────── + if (opts.extra && opts.extra.length > 0) { + push(ui.blank()); + for (const line of opts.extra) push(ui.subtitle(` ${line}`)); + } + + // ── Actions ─────────────────────────────────────────────────────── + push(ui.blank()); + for (let i = 0; i < allActions.length; i++) { + const action = allActions[i]; + const isSelected = i === cursorIdx; + const isNotYet = action.id === "not_yet"; + const tag = action.recommended ? "(recommended)" : undefined; + + if (isSelected) { + push(ui.actionSelected(i + 1, action.label, action.description, tag)); + } else if (isNotYet) { + push(ui.actionDim(i + 1, action.label, action.description)); + } else { + push(ui.actionUnselected(i + 1, action.label, action.description, tag)); + } + push(ui.blank()); + } + + // ── Footer ──────────────────────────────────────────────────────── + const numHint = allActions.map((_, i) => `${i + 1}`).join("/"); + push(ui.hints([`↑/↓ to choose`, `${numHint} to quick-select`, `enter to confirm`])); + push(ui.bar()); + + cachedLines = lines; + return lines; + } + + return { render, invalidate: () => { cachedLines = undefined; }, handleInput }; + }); +} diff --git a/src/resources/extensions/shared/progress-widget.ts b/src/resources/extensions/shared/progress-widget.ts new file mode 100644 index 000000000..de29fb8dc --- /dev/null +++ b/src/resources/extensions/shared/progress-widget.ts @@ -0,0 +1,282 @@ +/** + * Shared persistent progress/status panel widget. + * + * Renders an ordered list of progress items with status glyphs, optional + * badge, subtitle, metadata, and footer hints. Supports pulse animation + * for active items during agent execution. + * + * Usage: + * + * import { createProgressPanel } from "./shared/progress-widget.js"; + * + * const panel = createProgressPanel(ctx.ui, { + * widgetKey: "workflow", + * statusKey: "workflow", + * statusPrefix: "wf", + * }); + * + * panel.update(model); // render/re-render with new model + * panel.startPulse(); // animate active items + * panel.stopPulse(); // stop animation + * panel.dispose(); // remove widget and status + */ + +import type { ExtensionUIContext, Theme } from "@mariozechner/pi-coding-agent"; +import type { TUI } from "@mariozechner/pi-tui"; +import { makeUI, type ProgressStatus } from "./ui.js"; + +// ─── Exported types ─────────────────────────────────────────────────────────── + +export type ProgressItemStatus = ProgressStatus; + +export interface ProgressItem { + /** Display label */ + label: string; + /** Drives glyph and color */ + status: ProgressItemStatus; + /** Optional text after label — e.g. artifact type, task ID */ + detail?: string; + /** Optional secondary line below item — e.g. "waiting for /workflow-continue" */ + annotation?: string; +} + +export interface ProgressPanelModel { + /** Panel title */ + title: string; + /** Optional badge next to title — e.g. "RUNNING", "PAUSED" */ + badge?: string; + /** Badge color control — maps to ProgressItemStatus color */ + badgeStatus?: ProgressItemStatus; + /** Optional subtitle lines below title */ + subtitle?: string[]; + /** Ordered progress items */ + items: ProgressItem[]; + /** Optional metadata lines below items */ + meta?: string[]; + /** Optional footer hint strings */ + hints?: string[]; +} + +export interface ProgressPanelOptions { + /** + * Widget key used with ctx.ui.setWidget(...). + * Must be unique per extension. + */ + widgetKey: string; + /** + * Status key used with ctx.ui.setStatus(...). + * Must be unique per extension. + */ + statusKey: string; + /** + * Short prefix for footer status text. + * Example: "wf" produces "wf:2/3 RUNNING" + */ + statusPrefix: string; +} + +export interface ProgressPanel { + /** Update the widget with a new model. Triggers re-render. */ + update(model: ProgressPanelModel): void; + /** Start pulsing items with status "active". */ + startPulse(): void; + /** Stop pulsing. Active items render at full brightness. */ + stopPulse(): void; + /** Remove the widget and status from the UI. */ + dispose(): void; +} + +// ─── Internal constants ─────────────────────────────────────────────────────── + +const PULSE_INTERVAL_MS = 500; + +// ─── Implementation ─────────────────────────────────────────────────────────── + +/** + * Create and register a persistent progress widget. + * + * @param ui The `ctx.ui` object from ExtensionContext or ExtensionCommandContext + * @param options Widget key, status key, and status prefix + * @returns ProgressPanel controller + */ +export function createProgressPanel( + ui: ExtensionUIContext, + options: ProgressPanelOptions, +): ProgressPanel { + const { widgetKey, statusKey, statusPrefix } = options; + + // ── Internal state ──────────────────────────────────────────────────────── + + let currentModel: ProgressPanelModel | null = null; + let stateVersion = 0; + let cachedLines: string[] | undefined; + let cachedWidth: number | undefined; + let cachedVersion = -1; + let pulseBright = true; + let pulseTimer: ReturnType<typeof setInterval> | null = null; + let widgetRef: { invalidate: () => void; requestRender: () => void } | null = null; + + // ── Footer status ───────────────────────────────────────────────────────── + + function updateFooterStatus(): void { + if (!currentModel) return; + const { items, badge } = currentModel; + const total = items.length; + let current = 0; + + // Find first active item index (1-based) + const activeIdx = items.findIndex((it) => it.status === "active"); + if (activeIdx >= 0) { + current = activeIdx + 1; + } else { + // Count done items + 1 + current = items.filter((it) => it.status === "done").length + 1; + } + if (current > total) current = total; + + const badgePart = badge ? ` ${badge}` : ""; + const statusText = ui.theme.fg("accent", `${statusPrefix}:${current}/${total}${badgePart}`); + ui.setStatus(statusKey, statusText); + } + + // ── Render function ─────────────────────────────────────────────────────── + + function renderPanel(width: number, theme: Theme): string[] { + // Version-based cache check + if (cachedLines && cachedWidth === width && cachedVersion === stateVersion) { + return cachedLines; + } + + if (!currentModel) return []; + + const uiHelper = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + const model = currentModel; + + // 1. Top bar + push(uiHelper.bar()); + + // 2. Title area — title with optional inline badge + if (model.badge && model.badgeStatus) { + const titleText = uiHelper.header(model.title)[0]; + const badgeGlyph = uiHelper.statusGlyph(model.badgeStatus); + const badgeLabel = uiHelper.statusBadge(model.badge, model.badgeStatus)[0]; + lines.push(`${titleText} ${badgeGlyph} ${badgeLabel.trimStart()}`); + } else { + push(uiHelper.header(model.title)); + } + + // 3. Subtitle + if (model.subtitle?.length) { + for (const line of model.subtitle) { + push(uiHelper.meta(line)); + } + } + + // 4. Blank line + push(uiHelper.blank()); + + // 5. Items + for (const item of model.items) { + // Pulse: when pulseBright is false and item is active, render as pending (dimmed) + const renderStatus: ProgressStatus = (!pulseBright && item.status === "active") + ? "pending" + : item.status; + + push(uiHelper.progressItem(item.label, renderStatus, { + detail: item.detail, + emphasized: item.status === "active", + })); + + if (item.annotation) { + push(uiHelper.progressAnnotation(item.annotation)); + } + } + + // 6. Blank line (if meta or hints follow) + if (model.meta?.length || model.hints?.length) { + push(uiHelper.blank()); + } + + // 7. Meta + if (model.meta?.length) { + for (const line of model.meta) { + push(uiHelper.meta(line)); + } + } + + // 8. Hints + if (model.hints?.length) { + push(uiHelper.hints(model.hints)); + } + + // 9. Bottom bar + push(uiHelper.bar()); + + cachedLines = lines; + cachedWidth = width; + cachedVersion = stateVersion; + return lines; + } + + // ── Register widget ─────────────────────────────────────────────────────── + + ui.setWidget(widgetKey, (tui: TUI, theme: Theme) => { + widgetRef = { + invalidate: () => { cachedLines = undefined; }, + requestRender: () => tui.requestRender(), + }; + + return { + render(width: number): string[] { + return renderPanel(width, theme); + }, + invalidate() { + cachedLines = undefined; + }, + }; + }); + + // ── Controller ──────────────────────────────────────────────────────────── + + return { + update(model: ProgressPanelModel): void { + currentModel = model; + stateVersion++; + cachedLines = undefined; + updateFooterStatus(); + if (widgetRef) widgetRef.requestRender(); + }, + + startPulse(): void { + if (pulseTimer) return; // already pulsing + pulseTimer = setInterval(() => { + pulseBright = !pulseBright; + cachedLines = undefined; + if (widgetRef) widgetRef.requestRender(); + }, PULSE_INTERVAL_MS); + }, + + stopPulse(): void { + if (pulseTimer) { + clearInterval(pulseTimer); + pulseTimer = null; + } + pulseBright = true; + cachedLines = undefined; + if (widgetRef) widgetRef.requestRender(); + }, + + dispose(): void { + if (pulseTimer) { + clearInterval(pulseTimer); + pulseTimer = null; + } + ui.setWidget(widgetKey, undefined); + ui.setStatus(statusKey, undefined); + currentModel = null; + widgetRef = null; + }, + }; +} diff --git a/src/resources/extensions/shared/thinking-widget.ts b/src/resources/extensions/shared/thinking-widget.ts new file mode 100644 index 000000000..1f37fde4a --- /dev/null +++ b/src/resources/extensions/shared/thinking-widget.ts @@ -0,0 +1,107 @@ +/** + * Shared thinking/spinner widget. + * + * Shows an animated spinner with a label and an optional live-preview of + * streamed text (e.g. LLM output) while a background operation is running. + * + * Usage: + * + * import { showThinkingWidget } from "./shared/thinking-widget.js"; + * + * const widget = showThinkingWidget("Generating questions…", ctx); + * + * // Optionally stream partial text into the preview line: + * widget.setText(partialLlmOutput); + * + * // Always dispose when done — removes the widget from the UI: + * widget.dispose(); + * + * Each call gets a unique widget key derived from a monotonic counter, so + * multiple widgets can safely coexist without key collisions. + */ + +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { type Theme } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, type TUI } from "@mariozechner/pi-tui"; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export interface ThinkingWidget { + /** + * Update the streamed-text preview line. + * Pass the full accumulated text — the widget trims and previews the tail. + */ + setText(text: string): void; + /** Remove the widget from the UI. Always call this when the operation completes. */ + dispose(): void; +} + +// ─── Internal constants ─────────────────────────────────────────────────────── + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; +const SPINNER_INTERVAL_MS = 80; +const PREVIEW_MAX_CHARS = 120; + +let widgetCounter = 0; + +// ─── Implementation ─────────────────────────────────────────────────────────── + +/** + * Show an animated thinking spinner as a TUI widget. + * + * @param label Short description of what is happening, e.g. "Writing PROJECT.md…" + * @param ctx Extension command context + * @returns Handle with setText() and dispose() + */ +export function showThinkingWidget(label: string, ctx: ExtensionCommandContext): ThinkingWidget { + const widgetKey = `thinking-widget-${++widgetCounter}`; + + let streamedText = ""; + let widgetRef: { invalidate: () => void; requestRender: () => void } | null = null; + + ctx.ui.setWidget(widgetKey, (tui: TUI, theme: Theme) => { + let frame = 0; + let cachedLines: string[] | undefined; + + const interval = setInterval(() => { + frame = (frame + 1) % SPINNER_FRAMES.length; + cachedLines = undefined; + tui.requestRender(); + }, SPINNER_INTERVAL_MS); + + widgetRef = { + invalidate: () => { cachedLines = undefined; }, + requestRender: () => tui.requestRender(), + }; + + return { + render(width: number): string[] { + if (cachedLines) return cachedLines; + const spinner = theme.fg("accent", SPINNER_FRAMES[frame]); + const lines: string[] = []; + lines.push(truncateToWidth(` ${spinner} ${theme.fg("muted", label)}`, width)); + if (streamedText) { + const preview = streamedText.replace(/\s+/g, " ").trim().slice(-PREVIEW_MAX_CHARS); + lines.push(truncateToWidth(` ${theme.fg("dim", preview)}`, width)); + } + cachedLines = lines; + return lines; + }, + invalidate() { cachedLines = undefined; }, + dispose() { clearInterval(interval); }, + }; + }); + + return { + setText(text: string) { + streamedText = text; + if (widgetRef) { + widgetRef.invalidate(); + widgetRef.requestRender(); + } + }, + dispose() { + ctx.ui.setWidget(widgetKey, undefined); + }, + }; +} diff --git a/src/resources/extensions/shared/ui.ts b/src/resources/extensions/shared/ui.ts new file mode 100644 index 000000000..ad9d20012 --- /dev/null +++ b/src/resources/extensions/shared/ui.ts @@ -0,0 +1,400 @@ +/** + * Shared UI design system for GSD/interview TUI components. + * + * Centralises all colours, glyphs, spacing, and layout helpers so every + * screen looks consistent and can be restyled from one place. + * + * Usage: + * + * import { makeUI } from "./shared/ui.js"; + * + * // Inside ctx.ui.custom((tui, theme, _kb, done) => { ... }): + * const ui = makeUI(theme, width); + * + * // Then in render(width): + * const ui = makeUI(theme, width); + * lines.push(...ui.bar()); + * lines.push(...ui.header("New Project")); + * lines.push(...ui.blank()); + * lines.push(...ui.question("What do you want to build?")); + * lines.push(...ui.optionSelected(1, "Describe it now", "Type what you want.")); + * lines.push(...ui.optionUnselected(2, "Provide a file", "Point to an existing doc.")); + * lines.push(...ui.blank()); + * lines.push(...ui.hints(["↑/↓ to move", "enter to select"])); + * lines.push(...ui.bar()); + * + * Every method returns string[] (one or more lines) so you can spread + * directly into your lines array. Width is passed once to makeUI so + * individual methods don't need it. + */ + +import { type Theme } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; + +// ─── Glyphs ─────────────────────────────────────────────────────────────────── +// Change these to restyle every cursor, checkbox, and indicator at once. + +export const GLYPH = { + cursor: "›", + check: "✓", + checkedBox: "[x]", + uncheckedBox: "[ ]", + dotActive: "●", + dotDone: "●", + squareFilled: "■", + squareEmpty: "□", + separator: "─", + statusPending: "○", + statusActive: "●", + statusDone: "✓", + statusFailed: "✗", + statusPaused: "⏸", + statusWarning: "⚠", + statusSkipped: "–", +} as const; + +// ─── Status vocabulary ──────────────────────────────────────────────────────── +// Shared status type and visual mappings used by any component that renders +// progress or state indicators. + +export type ProgressStatus = + | "pending" + | "active" + | "done" + | "failed" + | "paused" + | "warning" + | "skipped"; + +export const STATUS_COLOR: Record<ProgressStatus, "dim" | "accent" | "success" | "error" | "warning"> = { + pending: "dim", + active: "accent", + done: "success", + failed: "error", + paused: "warning", + warning: "warning", + skipped: "dim", +}; + +export const STATUS_GLYPH: Record<ProgressStatus, string> = { + pending: GLYPH.statusPending, + active: GLYPH.statusActive, + done: GLYPH.statusDone, + failed: GLYPH.statusFailed, + paused: GLYPH.statusPaused, + warning: GLYPH.statusWarning, + skipped: GLYPH.statusSkipped, +}; + +// ─── Spacing ────────────────────────────────────────────────────────────────── +// All indentation constants in one place. + +export const INDENT = { + /** Standard left margin for all content lines */ + base: " ", + /** Option label indent (same as base, kept separate for clarity) */ + option: " ", + /** Description line below an option label */ + description: " ", + /** Note line below a review answer */ + note: " ", + /** Cursor + space (replaces base when cursor is shown) */ + cursor: "› ", +} as const; + +// ─── Factory ────────────────────────────────────────────────────────────────── + +export interface UI { + // ── Layout ──────────────────────────────────────────────────────────────── + /** Full-width accent separator bar */ + bar(): string[]; + /** Empty line */ + blank(): string[]; + + // ── Text elements ───────────────────────────────────────────────────────── + /** Bold accent title — used for screen headings */ + header(text: string): string[]; + /** Standard question or page subtitle */ + question(text: string): string[]; + /** Muted secondary text — used for subtitles and review question labels */ + subtitle(text: string): string[]; + /** Dim metadata / progress line */ + meta(text: string): string[]; + /** Dim footer hint line — pipe-separated hints */ + hints(parts: string[]): string[]; + /** Dim note line (e.g. "note: ...") */ + note(text: string): string[]; + /** Success-coloured confirmed answer line (e.g. "✓ Option A") */ + answer(text: string): string[]; + + // ── Select options ──────────────────────────────────────────────────────── + /** + * Single-select option row — cursor highlighted. + * Pass isCommitted=true to show the ✓ marker. + */ + optionSelected(num: number, label: string, description: string, isCommitted?: boolean): string[]; + /** + * Single-select option row — not under cursor. + * Pass isFocusDimmed=true when notes field is focused (dims everything). + */ + optionUnselected(num: number, label: string, description: string, opts?: { isCommitted?: boolean; isFocusDimmed?: boolean }): string[]; + + // ── Checkbox options ────────────────────────────────────────────────────── + /** Multi-select option row — cursor highlighted */ + checkboxSelected(label: string, description: string, isChecked: boolean): string[]; + /** Multi-select option row — not under cursor */ + checkboxUnselected(label: string, description: string, isChecked: boolean, isFocusDimmed?: boolean): string[]; + + // ── Special slots ───────────────────────────────────────────────────────── + /** "None of the above" / "Done" slot — selected state */ + slotSelected(label: string, description: string, isCommitted?: boolean): string[]; + /** "None of the above" / "Done" slot — unselected state */ + slotUnselected(label: string, description: string, opts?: { isCommitted?: boolean; isFocusDimmed?: boolean }): string[]; + /** Multi-select "Done" slot — selected */ + doneSelected(): string[]; + /** Multi-select "Done" slot — unselected */ + doneUnselected(): string[]; + + // ── Action items (next-action style) ────────────────────────────────────── + /** Accent action item with cursor — used in next-action and review screens */ + actionSelected(num: number, label: string, description?: string, tag?: string): string[]; + /** Unselected action item */ + actionUnselected(num: number, label: string, description?: string, tag?: string): string[]; + /** Dim "not yet" style action — least prominent */ + actionDim(num: number, label: string, description?: string): string[]; + + // ── Progress indicators ─────────────────────────────────────────────────── + /** Row of page dots for wizard navigation */ + pageDots(total: number, currentIndex: number): string[]; + /** Interview question tab bar */ + questionTabs(headers: string[], currentIndex: number, answeredIndices: Set<number>): string[]; + + // ── Status primitives ───────────────────────────────────────────────────── + /** Render a status glyph in the appropriate theme color */ + statusGlyph(status: ProgressStatus): string; + /** Render a status badge — bold text in the appropriate status color */ + statusBadge(text: string, status: ProgressStatus): string[]; + /** Render a progress item row: glyph + label + optional detail */ + progressItem( + label: string, + status: ProgressStatus, + opts?: { detail?: string; emphasized?: boolean }, + ): string[]; + /** Render an indented annotation line below a progress item */ + progressAnnotation(text: string): string[]; + + // ── Notes area ──────────────────────────────────────────────────────────── + /** Notes section label — accent when focused, muted when not */ + notesLabel(focused: boolean): string[]; + /** Inline note text (dim) */ + notesText(text: string): string[]; + + // ── Editor theme ────────────────────────────────────────────────────────── + /** Standard EditorTheme object for use with the Editor component */ + editorTheme: import("@mariozechner/pi-tui").EditorTheme; +} + +/** + * Create a UI helper bound to the current theme and render width. + * Call once per render() invocation (width may change between renders). + */ +export function makeUI(theme: Theme, width: number): UI { + // ── Internal helpers ─────────────────────────────────────────────────────── + + const add = (s: string): string => truncateToWidth(s, width); + const wrap = (s: string): string[] => wrapTextWithAnsi(s, width); + + function wrapIndented(s: string, indent: string): string[] { + const indentWidth = visibleWidth(indent); + const wrapped = wrapTextWithAnsi(s, width - indentWidth); + for (let i = 1; i < wrapped.length; i++) wrapped[i] = indent + wrapped[i]; + return wrapped; + } + + const bar = theme.fg("accent", GLYPH.separator.repeat(width)); + + // ── EditorTheme ──────────────────────────────────────────────────────────── + + const editorTheme: import("@mariozechner/pi-tui").EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + + // ── UI implementation ────────────────────────────────────────────────────── + + return { + editorTheme, + + // ── Layout ────────────────────────────────────────────────────────────── + + bar: () => [bar], + blank: () => [""], + + // ── Text elements ──────────────────────────────────────────────────────── + + header: (text) => [add(theme.fg("accent", theme.bold(text)))], + + question: (text) => wrap(theme.fg("text", text)), + + subtitle: (text) => wrap(theme.fg("text", text)), + + meta: (text) => [add(theme.fg("dim", text))], + + hints: (parts) => [add(theme.fg("dim", ` ${parts.join(" | ")}`))], + + note: (text) => [add(theme.fg("dim", text))], + + answer: (text) => [add(theme.fg("success", text))], + + // ── Single-select options ──────────────────────────────────────────────── + + optionSelected: (num, label, description, isCommitted = false) => { + const marker = isCommitted ? theme.fg("success", ` ${GLYPH.check}`) : ""; + const prefix = `${INDENT.option}${theme.fg("accent", INDENT.cursor)}`; + return [ + ...wrap(`${prefix}${theme.fg("accent", `${num}. ${label}`)}${marker}`), + ...wrapIndented(`${INDENT.description}${theme.fg("muted", description)}`, INDENT.description), + ]; + }, + + optionUnselected: (num, label, description, opts = {}) => { + const { isCommitted = false, isFocusDimmed = false } = opts; + const marker = isCommitted ? theme.fg("success", ` ${GLYPH.check}`) : ""; + const labelColor = isFocusDimmed ? "dim" : "text"; + const descColor = isFocusDimmed ? "dim" : "muted"; + return [ + ...wrap(`${INDENT.option} ${theme.fg(labelColor, `${num}. ${label}`)}${marker}`), + ...wrapIndented(`${INDENT.description}${theme.fg(descColor, description)}`, INDENT.description), + ]; + }, + + // ── Multi-select options ───────────────────────────────────────────────── + + checkboxSelected: (label, description, isChecked) => { + const box = isChecked ? theme.fg("success", GLYPH.checkedBox) : theme.fg("dim", GLYPH.uncheckedBox); + return [ + add(`${INDENT.option}${theme.fg("accent", GLYPH.cursor)} ${box} ${theme.fg("accent", label)}`), + ...wrapIndented(`${INDENT.description}${theme.fg("muted", description)}`, INDENT.description), + ]; + }, + + checkboxUnselected: (label, description, isChecked, isFocusDimmed = false) => { + const box = isChecked ? theme.fg("success", GLYPH.checkedBox) : theme.fg("dim", GLYPH.uncheckedBox); + const labelColor = isFocusDimmed ? (isChecked ? "text" : "dim") : "text"; + const descColor = isFocusDimmed ? "dim" : "muted"; + return [ + add(`${INDENT.option} ${box} ${theme.fg(labelColor, label)}`), + ...wrapIndented(`${INDENT.description}${theme.fg(descColor, description)}`, INDENT.description), + ]; + }, + + // ── Special slots ──────────────────────────────────────────────────────── + + slotSelected: (label, description, isCommitted = false) => { + const marker = isCommitted ? theme.fg("success", ` ${GLYPH.check}`) : ""; + return [ + ...wrap(`${INDENT.option}${theme.fg("accent", `${GLYPH.cursor}${label}`)}${marker}`), + ...wrapIndented(`${INDENT.description}${theme.fg("muted", description)}`, INDENT.description), + ]; + }, + + slotUnselected: (label, description, opts = {}) => { + const { isCommitted = false, isFocusDimmed = false } = opts; + const marker = isCommitted ? theme.fg("success", ` ${GLYPH.check}`) : ""; + const labelColor = isFocusDimmed ? "dim" : "text"; + const descColor = isFocusDimmed ? "dim" : "muted"; + return [ + ...wrap(`${INDENT.option} ${theme.fg(labelColor, label)}${marker}`), + ...wrapIndented(`${INDENT.description}${theme.fg(descColor, description)}`, INDENT.description), + ]; + }, + + doneSelected: () => [ + add(`${INDENT.option}${theme.fg("accent", INDENT.cursor)}${theme.bold(theme.fg("accent", "Done"))}`), + ], + + doneUnselected: () => [ + add(theme.fg("dim", `${INDENT.option} Done`)), + ], + + // ── Action items ───────────────────────────────────────────────────────── + + actionSelected: (num, label, description, tag) => { + const tagStr = tag ? theme.fg("dim", ` ${tag}`) : ""; + const lines = [add(`${INDENT.option}${theme.fg("accent", GLYPH.cursor)} ${theme.fg("accent", `${num}. ${label}`)}${tagStr}`)]; + if (description) lines.push(...wrap(`${INDENT.description}${theme.fg("muted", description)}`)); + return lines; + }, + + actionUnselected: (num, label, description, tag) => { + const tagStr = tag ? theme.fg("dim", ` ${tag}`) : ""; + const lines = [add(`${INDENT.option} ${theme.fg("text", `${num}. ${label}`)}${tagStr}`)]; + if (description) lines.push(...wrap(`${INDENT.description}${theme.fg("dim", description)}`)); + return lines; + }, + + actionDim: (num, label, description) => { + const lines = [add(`${INDENT.option} ${theme.fg("dim", `${num}. ${label}`)}`)]; + if (description) lines.push(...wrap(`${INDENT.description}${theme.fg("dim", description)}`)); + return lines; + }, + + // ── Progress indicators ─────────────────────────────────────────────────── + + pageDots: (total, currentIndex) => { + const dots = Array.from({ length: total }, (_, i) => + i === currentIndex + ? theme.fg("accent", GLYPH.dotActive) + : i < currentIndex + ? theme.fg("success", GLYPH.dotDone) + : theme.fg("dim", GLYPH.dotActive), + ).join(theme.fg("dim", " → ")); + return [add(`${INDENT.base}${dots}`)]; + }, + + questionTabs: (headers, currentIndex, answeredIndices) => { + const parts = headers.map((header, i) => { + const isCurrent = i === currentIndex; + const isAnswered = answeredIndices.has(i); + const label = ` ${isAnswered ? GLYPH.squareFilled : GLYPH.squareEmpty} ${header} `; + return isCurrent + ? theme.bg("selectedBg", theme.fg("text", label)) + : theme.fg(isAnswered ? "success" : "muted", label); + }); + return [add(` ← ${parts.join(" ")} →`)]; + }, + + // ── Status primitives ────────────────────────────────────────────────────── + + statusGlyph: (status) => theme.fg(STATUS_COLOR[status], STATUS_GLYPH[status]), + + statusBadge: (text, status) => { + const color = STATUS_COLOR[status]; + return [add(`${INDENT.base}${theme.fg(color, theme.bold(text))}`)]; + }, + + progressItem: (label, status, opts = {}) => { + const glyph = theme.fg(STATUS_COLOR[status], STATUS_GLYPH[status]); + const labelColor = status === "done" ? "muted" : status === "pending" || status === "skipped" ? "dim" : "text"; + const labelText = opts.emphasized ? theme.bold(theme.fg(labelColor, label)) : theme.fg(labelColor, label); + const detailText = opts.detail ? ` ${theme.fg("dim", opts.detail)}` : ""; + return [add(`${INDENT.base}${glyph} ${labelText}${detailText}`)]; + }, + + progressAnnotation: (text) => [add(`${INDENT.description}${theme.fg("dim", text)}`)], + + // ── Notes area ──────────────────────────────────────────────────────────── + + notesLabel: (focused) => [ + add(focused ? theme.fg("accent", " Notes:") : theme.fg("muted", " Notes:")), + ], + + notesText: (text) => wrapIndented(` ${theme.fg("dim", text)}`, " "), + }; +} diff --git a/src/resources/extensions/shared/wizard-ui.ts b/src/resources/extensions/shared/wizard-ui.ts new file mode 100644 index 000000000..ac3224131 --- /dev/null +++ b/src/resources/extensions/shared/wizard-ui.ts @@ -0,0 +1,551 @@ +/** + * General-purpose multi-page wizard UI. + * + * Supports declarative page definitions with select and text fields. + * Pages can conditionally route to different next pages based on answers. + * + * Navigation: + * ← go back one page (on page 1: triggers exit confirmation) + * → / Enter advance to next page (or submit on last page) + * Escape triggers exit confirmation overlay + * + * Exit confirmation (shown on Escape or ← from page 1): + * 1. Go back — dismiss and return to current page + * 2. Exit — cancel the wizard, returns null to caller + * + * Returns: + * Record<pageId, Record<fieldId, string | string[]>> on completion + * null on exit/cancel + * + * Example: + * + * const result = await showWizard(ctx, { + * title: "New Project", + * pages: [ + * { + * id: "mode", + * fields: [ + * { + * type: "select", + * id: "start_type", + * question: "How do you want to start?", + * options: [ + * { label: "Describe it", description: "Type what you want to build." }, + * { label: "Provide a file", description: "Point to an existing doc." }, + * ], + * }, + * ], + * next: (answers) => + * answers["mode"]?.["start_type"] === "Provide a file" ? "file_path" : null, + * }, + * { + * id: "file_path", + * fields: [ + * { type: "text", id: "path", label: "File path", placeholder: "/path/to/doc.md" }, + * ], + * next: () => null, + * }, + * ], + * }); + * + * if (!result) return; // user exited + * const startType = result["mode"]["start_type"]; // "Describe it" | "Provide a file" + * const filePath = result["file_path"]?.["path"]; + */ + +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { type Theme } from "@mariozechner/pi-coding-agent"; +import { + Editor, + Key, + matchesKey, + truncateToWidth, + type TUI, +} from "@mariozechner/pi-tui"; +import { makeUI } from "./ui.js"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export interface WizardOption { + label: string; + description: string; +} + +export interface SelectField { + type: "select"; + id: string; + question: string; + options: WizardOption[]; + /** Allow multiple selections. Default: false. */ + allowMultiple?: boolean; +} + +export interface TextField { + type: "text"; + id: string; + label: string; + placeholder?: string; +} + +export type WizardField = SelectField | TextField; + +/** Answers collected so far: pageId → fieldId → value */ +export type WizardAnswers = Record<string, Record<string, string | string[]>>; + +export interface WizardPage { + id: string; + /** Optional subtitle shown below the wizard title for this page. */ + subtitle?: string; + fields: WizardField[]; + /** + * Return the id of the next page, or null to end the wizard. + * Called with all answers collected so far when the user advances. + * If omitted, the wizard ends after this page. + */ + next?: (answers: WizardAnswers) => string | null; +} + +export interface WizardOptions { + /** Title shown at the top of every page. */ + title: string; + /** Ordered page definitions. Pages are navigated in order unless next() routes elsewhere. */ + pages: WizardPage[]; +} + +// ─── Internal state ─────────────────────────────────────────────────────────── + +interface SelectState { + cursorIndex: number; + /** Single-select: committed option index, null if not yet chosen */ + committedIndex: number | null; + /** Multi-select: which indices are checked */ + checkedIndices: Set<number>; +} + +interface PageState { + selectStates: Map<string, SelectState>; + textValues: Map<string, string>; + /** Which field is focused (for text fields) */ + focusedFieldId: string | null; +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +/** + * Show a multi-page wizard and return collected answers, or null if the user exits. + */ +export async function showWizard( + ctx: ExtensionCommandContext, + opts: WizardOptions, +): Promise<WizardAnswers | null> { + const pageMap = new Map<string, WizardPage>(opts.pages.map((p) => [p.id, p])); + + return ctx.ui.custom<WizardAnswers | null>((tui: TUI, theme: Theme, _kb, done) => { + // ── State ────────────────────────────────────────────────────────────── + + /** Stack of page ids visited — drives back navigation */ + const pageStack: string[] = [opts.pages[0].id]; + const pageStates = new Map<string, PageState>(); + /** Collected answers across all pages */ + const answers: WizardAnswers = {}; + /** Whether the exit-confirmation overlay is showing */ + let showingExitConfirm = false; + /** Cursor in the exit-confirm overlay: 0 = go back, 1 = exit */ + let exitCursor = 0; + + let cachedLines: string[] | undefined; + + // Editors keyed by fieldId — one per text field + // editorTheme is derived from the design system at first render + const editors = new Map<string, Editor>(); + let resolvedEditorTheme: import("@mariozechner/pi-tui").EditorTheme | null = null; + + function getEditor(fieldId: string): Editor { + if (!resolvedEditorTheme) resolvedEditorTheme = makeUI(theme, 80).editorTheme; + if (!editors.has(fieldId)) editors.set(fieldId, new Editor(tui, resolvedEditorTheme)); + return editors.get(fieldId)!; + } + + // ── Page state helpers ───────────────────────────────────────────────── + + function getPageState(pageId: string): PageState { + if (!pageStates.has(pageId)) { + pageStates.set(pageId, { + selectStates: new Map(), + textValues: new Map(), + focusedFieldId: null, + }); + } + return pageStates.get(pageId)!; + } + + function getSelectState(pageId: string, fieldId: string, _optCount: number): SelectState { + const ps = getPageState(pageId); + if (!ps.selectStates.has(fieldId)) { + ps.selectStates.set(fieldId, { + cursorIndex: 0, + committedIndex: null, // nothing pre-committed — user must explicitly confirm + checkedIndices: new Set(), + }); + } + return ps.selectStates.get(fieldId)!; + } + + // ── Current page ─────────────────────────────────────────────────────── + + function currentPageId(): string { + return pageStack[pageStack.length - 1]; + } + + function currentPage(): WizardPage { + return pageMap.get(currentPageId())!; + } + + function currentPageState(): PageState { + return getPageState(currentPageId()); + } + + // ── Validation ───────────────────────────────────────────────────────── + + function isPageComplete(page: WizardPage, ps: PageState): boolean { + for (const field of page.fields) { + if (field.type === "select") { + const ss = ps.selectStates.get(field.id); + if (!ss) return false; + if (field.allowMultiple) { + if (ss.checkedIndices.size === 0) return false; + } else { + if (ss.committedIndex === null) return false; + } + } else { + const val = ps.textValues.get(field.id) ?? ""; + if (!val.trim()) return false; + } + } + return true; + } + + // ── Collect answers for a page ───────────────────────────────────────── + + function collectPageAnswers(page: WizardPage, ps: PageState): Record<string, string | string[]> { + const result: Record<string, string | string[]> = {}; + for (const field of page.fields) { + if (field.type === "select") { + const ss = ps.selectStates.get(field.id); + if (!ss) continue; + if (field.allowMultiple) { + result[field.id] = Array.from(ss.checkedIndices) + .sort((a, b) => a - b) + .map((i) => field.options[i].label); + } else { + if (ss.committedIndex !== null && ss.committedIndex < field.options.length) { + result[field.id] = field.options[ss.committedIndex].label; + } + } + } else { + result[field.id] = ps.textValues.get(field.id) ?? ""; + } + } + return result; + } + + // ── Auto-focus helper ────────────────────────────────────────────────── + + /** If a page's first field is a text field, focus it immediately on arrival. */ + function autoFocusPageIfText(pageId: string) { + const page = pageMap.get(pageId); + if (!page) return; + const firstField = page.fields[0]; + if (firstField?.type === "text") { + const ps = getPageState(pageId); + ps.focusedFieldId = firstField.id; + const editor = getEditor(firstField.id); + editor.setText(ps.textValues.get(firstField.id) ?? ""); + } + } + + // Auto-focus the first page if it starts with a text field + autoFocusPageIfText(opts.pages[0].id); + + // ── Navigation ───────────────────────────────────────────────────────── + + function advance() { + const page = currentPage(); + const ps = currentPageState(); + if (!isPageComplete(page, ps)) { + refresh(); + return; + } + + // Save text field values from editors + for (const field of page.fields) { + if (field.type === "text") { + ps.textValues.set(field.id, getEditor(field.id).getText().trim()); + } + } + + // Collect answers for this page + answers[page.id] = collectPageAnswers(page, ps); + + // Route to next page + const nextId = page.next ? page.next(answers) : null; + if (!nextId) { + // End of wizard + done(answers); + return; + } + + const nextPage = pageMap.get(nextId); + if (!nextPage) { + done(answers); + return; + } + + pageStack.push(nextId); + autoFocusPageIfText(nextId); + refresh(); + } + + function goBack() { + if (pageStack.length <= 1) { + // Already at first page — Esc here means exit + showingExitConfirm = true; + exitCursor = 0; + refresh(); + return; + } + pageStack.pop(); + autoFocusPageIfText(currentPageId()); + refresh(); + } + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + // ── Input handler ────────────────────────────────────────────────────── + + function handleInput(data: string) { + // ── Exit confirm overlay ───────────────────────────────────────── + if (showingExitConfirm) { + if (matchesKey(data, Key.up)) { exitCursor = 0; refresh(); return; } + if (matchesKey(data, Key.down)) { exitCursor = 1; refresh(); return; } + if (data === "1") { showingExitConfirm = false; refresh(); return; } + if (data === "2") { done(null); return; } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + if (exitCursor === 0) { showingExitConfirm = false; refresh(); } + else { done(null); } + return; + } + // Esc on the confirm screen = go back (dismiss confirm) + if (matchesKey(data, Key.escape)) { showingExitConfirm = false; refresh(); return; } + return; + } + + // ── Text field focus ───────────────────────────────────────────── + const ps = currentPageState(); + if (ps.focusedFieldId) { + const editor = getEditor(ps.focusedFieldId); + if (matchesKey(data, Key.escape)) { + // First Esc: unfocus the text field + ps.textValues.set(ps.focusedFieldId, editor.getText().trim()); + ps.focusedFieldId = null; + refresh(); + return; + } + if (matchesKey(data, Key.enter)) { + ps.textValues.set(ps.focusedFieldId, editor.getText().trim()); + ps.focusedFieldId = null; + advance(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + // ── Esc with no text field focused: go back (or exit if on page 1) ── + if (matchesKey(data, Key.escape)) { goBack(); return; } + + // ── Enter / → to advance ───────────────────────────────────────── + if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) { + // For single-select fields, commit cursor before advancing + const page = currentPage(); + for (const field of page.fields) { + if (field.type === "select" && !field.allowMultiple) { + const ss = getSelectState(currentPageId(), field.id, field.options.length); + if (ss.committedIndex === null) ss.committedIndex = ss.cursorIndex; + } + } + advance(); + return; + } + + // ── Select field interactions ──────────────────────────────────── + const page = currentPage(); + for (const field of page.fields) { + if (field.type !== "select") continue; + const ss = getSelectState(currentPageId(), field.id, field.options.length); + const totalOpts = field.options.length; + + if (matchesKey(data, Key.up)) { + ss.cursorIndex = (ss.cursorIndex - 1 + totalOpts) % totalOpts; + refresh(); return; + } + if (matchesKey(data, Key.down)) { + ss.cursorIndex = (ss.cursorIndex + 1) % totalOpts; + refresh(); return; + } + + if (field.allowMultiple) { + if (matchesKey(data, Key.space)) { + if (ss.checkedIndices.has(ss.cursorIndex)) ss.checkedIndices.delete(ss.cursorIndex); + else ss.checkedIndices.add(ss.cursorIndex); + refresh(); return; + } + } else { + // Numeric shortcut: press the number to select and immediately advance + if (data.length === 1 && data >= "1" && data <= "9") { + const idx = parseInt(data, 10) - 1; + if (idx < totalOpts) { + ss.cursorIndex = idx; + ss.committedIndex = idx; + advance(); + return; + } + } + // Enter/Space commit cursor and advance (Enter handled above, Space here) + if (matchesKey(data, Key.space)) { + ss.committedIndex = ss.cursorIndex; + advance(); + return; + } + } + // Only handle the first select field for nav + break; + } + } + + // ── Render ───────────────────────────────────────────────────────────── + + function renderExitConfirm(width: number): string[] { + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push( + ui.bar(), ui.blank(), + ui.header(" Exit wizard?"), + ui.blank(), + ui.subtitle(" Your progress will be lost."), + ui.blank(), + ); + + if (exitCursor === 0) push(ui.actionSelected(1, "Go back", "Return to where you were.")); + else push(ui.actionUnselected(1, "Go back", "Return to where you were.")); + push(ui.blank()); + if (exitCursor === 1) push(ui.actionSelected(2, "Exit", "Cancel and discard all answers.")); + else push(ui.actionUnselected(2, "Exit", "Cancel and discard all answers.")); + push( + ui.blank(), + ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]), + ui.bar(), + ); + return lines; + } + + function renderSelectField(ui: ReturnType<typeof makeUI>, field: SelectField, lines: string[]) { + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + const ss = getSelectState(currentPageId(), field.id, field.options.length); + const multi = !!field.allowMultiple; + + push(ui.question(` ${field.question}`)); + if (multi) push(ui.meta(" (select all that apply — space to toggle, enter to confirm)")); + push(ui.blank()); + + for (let i = 0; i < field.options.length; i++) { + const opt = field.options[i]; + const isCursor = i === ss.cursorIndex; + const isCommitted = i === ss.committedIndex; + + if (multi) { + const isChecked = ss.checkedIndices.has(i); + if (isCursor) push(ui.checkboxSelected(opt.label, opt.description, isChecked)); + else push(ui.checkboxUnselected(opt.label, opt.description, isChecked)); + } else { + if (isCursor) push(ui.optionSelected(i + 1, opt.label, opt.description, isCommitted)); + else push(ui.optionUnselected(i + 1, opt.label, opt.description, { isCommitted })); + } + } + } + + function renderTextField(ui: ReturnType<typeof makeUI>, field: TextField, ps: PageState, lines: string[], width: number) { + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + const isFocused = ps.focusedFieldId === field.id; + const value = isFocused ? getEditor(field.id).getText() : (ps.textValues.get(field.id) ?? ""); + + push(ui.question(` ${field.label}`), ui.blank()); + + if (isFocused) { + for (const line of getEditor(field.id).render(width - 2)) lines.push(truncateToWidth(` ${line}`, width)); + } else if (value) { + push(ui.answer(` ${value}`)); + } else if (field.placeholder) { + push(ui.meta(` ${field.placeholder}`)); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + if (showingExitConfirm) { cachedLines = renderExitConfirm(width); return cachedLines; } + + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push(ui.bar(), ui.header(` ${opts.title}`)); + + // ── Page indicator ──────────────────────────────────────────────── + if (opts.pages.length > 1) { + push(ui.pageDots(opts.pages.length, pageStack.length - 1)); + } + + // ── Page content ────────────────────────────────────────────────── + const page = currentPage(); + const ps = currentPageState(); + + if (page.subtitle) { push(ui.blank(), ui.subtitle(` ${page.subtitle}`)); } + push(ui.blank()); + + for (const field of page.fields) { + if (field.type === "select") renderSelectField(ui, field, lines); + else renderTextField(ui, field, ps, lines, width); + push(ui.blank()); + } + + // ── Footer hints ────────────────────────────────────────────────── + const isFirst = pageStack.length === 1; + const ps2 = currentPageState(); + const hints: string[] = []; + if (ps2.focusedFieldId) { + hints.push("enter to continue"); + hints.push("esc to unfocus"); + } else { + hints.push("↑/↓ to move"); + hints.push("enter to select"); + hints.push(!isFirst ? "esc to go back" : "esc to exit"); + } + push(ui.hints(hints), ui.bar()); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { cachedLines = undefined; }, + handleInput, + }; + }); +} diff --git a/src/resources/extensions/slash-commands/audit.ts b/src/resources/extensions/slash-commands/audit.ts new file mode 100644 index 000000000..25c3495c1 --- /dev/null +++ b/src/resources/extensions/slash-commands/audit.ts @@ -0,0 +1,88 @@ +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; + +export default function auditCommand(pi: ExtensionAPI) { + pi.registerCommand("audit", { + description: "Audit the current codebase against a specific goal and write a structured report to .gsd/audits/", + async handler(args: string, ctx: ExtensionCommandContext) { + // ── Step 1: Get the audit goal ──────────────────────────────────────── + + let goal = (typeof args === "string" ? args : "").trim(); + + if (!goal) { + const input = await ctx.ui.input( + "What is the audit goal?", + "e.g. understand performance bottlenecks before planning a roadmap", + ); + if (!input?.trim()) { + ctx.ui.notify("audit: No goal provided — cancelled.", "error"); + return; + } + goal = input.trim(); + } + + // ── Step 2: Build output path (.gsd/audits/<timestamp>-<slug>.md) ──── + + const now = new Date(); + const timestamp = now + .toISOString() + .replace(/T/, "-") + .replace(/:/g, "") + .replace(/\..+/, ""); + + const slug = goal + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + + const outputPath = `.gsd/audits/${timestamp}-${slug}.md`; + + // ── Step 3: Ensure the output directory exists ─────────────────────── + + await pi.exec("mkdir", ["-p", ".gsd/audits"]); + + // ── Step 4: Send the audit prompt to the agent ─────────────────────── + + const prompt = `You are conducting a codebase audit. This is a **read-only recce** — you will explore the codebase deeply and produce a structured report. You must NOT edit any code or create any files other than the audit report itself. + +## Audit Goal +${goal} + +## Your Task + +1. **Discover the codebase** — explore the project structure, key files, configuration, dependencies, and architecture. Use \`find\`, \`ls\`, \`cat\`, \`grep\`, and \`read\` freely. Read as deeply as needed to form a thorough opinion relative to the goal. + +2. **Analyse against the goal** — evaluate the codebase specifically through the lens of the audit goal. What already exists? What works well? What's missing, weak, or risky? + +3. **Write the audit report** to \`${outputPath}\` using exactly this markdown template: + +\`\`\`markdown +# Audit: ${goal} + +**Date:** ${now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} +**Goal:** ${goal} +**Codebase:** [detected project name / root path] + +--- + +## Strengths +<!-- What already exists that supports this goal? What's solid? --> + +## Gaps +<!-- What's missing, incomplete, or problematic relative to this goal? Be specific: file paths, patterns, missing abstractions. --> + +## Next Steps +<!-- Concrete, prioritised actions. These should be directly usable as input to /gsd-roadmap. --> + +--- + +*Generated by /audit — read-only recce, no code was modified.* +\`\`\` + +After writing the file, confirm with: "✅ Audit complete — report saved to \`${outputPath}\`"`; + + ctx.ui.notify(`Starting audit: "${goal}"`, "info"); + pi.sendUserMessage(prompt); + }, + }); +} diff --git a/src/resources/extensions/slash-commands/create-extension.ts b/src/resources/extensions/slash-commands/create-extension.ts new file mode 100644 index 000000000..6166bd753 --- /dev/null +++ b/src/resources/extensions/slash-commands/create-extension.ts @@ -0,0 +1,297 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { showInterviewRound, type Question, type RoundResult } from "../shared/interview-ui.js"; + +export default function createExtension(pi: ExtensionAPI) { + pi.registerCommand("create-extension", { + description: "Scaffold a new pi extension with interview-driven context gathering", + async handler(args, ctx) { + const inlineName = (typeof args === "string" ? args : "").trim(); + + // ── Interview — always runs first ───────────────────────────────────── + + const questions: Question[] = [ + ...(!inlineName + ? [ + { + id: "purpose", + header: "Purpose", + question: "What should this extension do?", + options: [ + { + label: "Add a custom tool", + description: "Register a new tool the LLM can call (like gsd_plan, plan_clarify).", + }, + { + label: "Add a slash command", + description: "A /command the user types — runs logic, optionally triggers an agent turn.", + }, + { + label: "React to agent events", + description: "Hook into turn_end, agent_end, tool_call, etc. to observe or intercept.", + }, + { + label: "Custom TUI component", + description: "Render a widget, overlay, dialog, or custom editor in the terminal UI.", + }, + ], + } satisfies Question, + ] + : []), + { + id: "ui", + header: "UI", + question: "Does this extension need any custom UI?", + options: [ + { + label: "No UI", + description: "Pure logic — no dialogs, widgets, or custom rendering needed.", + }, + { + label: "Dialogs only", + description: "Uses built-in ctx.ui.select / ctx.ui.input / ctx.ui.confirm dialogs.", + }, + { + label: "Status / widget", + description: "Shows a persistent status indicator or footer widget.", + }, + { + label: "Full custom component", + description: "Uses ctx.ui.custom() to render a fully bespoke TUI component.", + }, + ], + }, + { + id: "events", + header: "Events", + question: "Does it need to hook into the agent lifecycle?", + options: [ + { + label: "No — standalone", + description: "Runs only when explicitly invoked — no event listeners needed.", + }, + { + label: "Yes — tool_call", + description: "Intercepts or observes tool calls before or after they run.", + }, + { + label: "Yes — turn / session", + description: "Reacts to turn_end, agent_end, session_start, or similar lifecycle events.", + }, + { + label: "Yes — context / prompt", + description: "Modifies the system prompt or filters messages via context / before_agent_start.", + }, + ], + }, + { + id: "persistence", + header: "State", + question: "Does this extension need to persist state across sessions?", + options: [ + { + label: "No state needed", + description: "Stateless — each invocation is independent.", + }, + { + label: "In-memory only", + description: "Keeps state while the session is running but doesn't survive restarts.", + }, + { + label: "Persisted to session", + description: "Uses pi.appendEntry() to write state into the session JSONL for resume.", + }, + ], + }, + { + id: "complexity", + header: "Complexity", + question: "How complex is the implementation?", + options: [ + { + label: "Simple — one concern", + description: "A single tool or command, minimal branching, easy to follow.", + }, + { + label: "Moderate — a few parts", + description: "A command plus an event hook, or a tool with custom rendering.", + }, + { + label: "Complex — full extension", + description: "Multiple tools, commands, events, UI, and state working together.", + }, + ], + }, + ]; + + const result: RoundResult = await showInterviewRound( + questions, + { + progress: "New pi extension · Context", + reviewHeadline: "Review your choices", + exitHeadline: "Cancel extension creation?", + exitLabel: "cancel", + }, + ctx, + ); + + // User hit Esc — bail silently + if (!result.answers || Object.keys(result.answers).length === 0) { + ctx.ui.notify("Cancelled.", "info"); + return; + } + + // ── Resolve name / description ──────────────────────────────────────── + + let extensionDescription = inlineName; + if (!extensionDescription) { + const purpose = result.answers["purpose"]; + if (purpose) { + extensionDescription = purpose.notes?.trim() + ? purpose.notes.trim() + : Array.isArray(purpose.selected) ? purpose.selected[0] : purpose.selected; + } + } + + if (!extensionDescription) { + ctx.ui.notify("No description captured — add details in the notes field next time.", "warning"); + return; + } + + // ── Build and send the enriched prompt ──────────────────────────────── + + sendPrompt(extensionDescription, result, pi); + }, + }); +} + +// ─── Prompt builder ─────────────────────────────────────────────────────────── + +function formatAnswers(result: RoundResult): string { + const lines: string[] = []; + + const purpose = result.answers["purpose"]; + if (purpose?.notes) { + lines.push(`- **Extension goal (user's words)**: ${purpose.notes}`); + } + + const ui = result.answers["ui"]; + if (ui) { + const selected = Array.isArray(ui.selected) ? ui.selected[0] : ui.selected; + lines.push(`- **UI needs**: ${selected}${ui.notes ? ` — ${ui.notes}` : ""}`); + } + + const events = result.answers["events"]; + if (events) { + const selected = Array.isArray(events.selected) ? events.selected[0] : events.selected; + lines.push(`- **Event hooks**: ${selected}${events.notes ? ` — ${events.notes}` : ""}`); + } + + const persistence = result.answers["persistence"]; + if (persistence) { + const selected = Array.isArray(persistence.selected) ? persistence.selected[0] : persistence.selected; + lines.push(`- **State persistence**: ${selected}${persistence.notes ? ` — ${persistence.notes}` : ""}`); + } + + const complexity = result.answers["complexity"]; + if (complexity) { + const selected = Array.isArray(complexity.selected) ? complexity.selected[0] : complexity.selected; + lines.push(`- **Complexity**: ${selected}${complexity.notes ? ` — ${complexity.notes}` : ""}`); + } + + return lines.join("\n"); +} + +function sendPrompt(description: string, result: RoundResult, pi: ExtensionAPI): void { + const contextSection = `\n## Context gathered from user\n${formatAnswers(result)}\n`; + + // Determine which doc sections to highlight based on answers + const uiAnswer = result.answers["ui"]; + const uiSelected = uiAnswer + ? (Array.isArray(uiAnswer.selected) ? uiAnswer.selected[0] : uiAnswer.selected) + : ""; + + const eventsAnswer = result.answers["events"]; + const eventsSelected = eventsAnswer + ? (Array.isArray(eventsAnswer.selected) ? eventsAnswer.selected[0] : eventsAnswer.selected) + : ""; + + const persistenceAnswer = result.answers["persistence"]; + const persistenceSelected = persistenceAnswer + ? (Array.isArray(persistenceAnswer.selected) ? persistenceAnswer.selected[0] : persistenceAnswer.selected) + : ""; + + const docHints: string[] = [ + "- `~/.pi/agent/docs/extending-pi/01-what-are-extensions.md` — capabilities overview", + "- `~/.pi/agent/docs/extending-pi/03-getting-started.md` — minimal extension, hot reload", + "- `~/.pi/agent/docs/extending-pi/08-extensioncontext-what-you-can-access.md` — ExtensionContext API", + "- `~/.pi/agent/docs/extending-pi/09-extensionapi-what-you-can-do.md` — ExtensionAPI: registration, messaging", + "- `~/.pi/agent/docs/extending-pi/22-key-rules-gotchas.md` — must-read rules before shipping", + ]; + + if (uiSelected.includes("custom component")) { + docHints.push("- `~/.pi/agent/docs/extending-pi/12-custom-ui-visual-components.md` — dialogs, widgets, overlays"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/06-ctx-ui-custom-full-custom-components.md` — ctx.ui.custom() API"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/07-built-in-components-the-building-blocks.md` — Text, Box, SelectList"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/09-keyboard-input-how-to-handle-keys.md` — Key, matchesKey"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/10-line-width-the-cardinal-rule.md` — truncation, width rules"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/19-building-a-complete-component-step-by-step.md` — step-by-step guide"); + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/21-common-mistakes-and-how-to-avoid-them.md` — pitfalls"); + } else if (uiSelected.includes("Dialogs")) { + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/04-built-in-dialog-methods.md` — select, confirm, input, editor"); + } else if (uiSelected.includes("Status")) { + docHints.push("- `~/.pi/agent/docs/pi-ui-tui/05-persistent-ui-elements.md` — status, widgets, footer, header"); + } + + if (uiSelected.includes("tool") || result.answers["purpose"]) { + docHints.push("- `~/.pi/agent/docs/extending-pi/14-custom-rendering-controlling-what-the-user-sees.md` — renderCall / renderResult"); + } + + if (eventsSelected && !eventsSelected.includes("standalone")) { + docHints.push("- `~/.pi/agent/docs/extending-pi/07-events-the-nervous-system.md` — all events reference"); + } + + if (eventsSelected.includes("context / prompt")) { + docHints.push("- `~/.pi/agent/docs/extending-pi/15-system-prompt-modification.md` — system prompt hooks"); + } + + if (persistenceSelected.includes("session")) { + docHints.push("- `~/.pi/agent/docs/extending-pi/13-state-management-persistence.md` — pi.appendEntry, session state"); + } + + const prompt = `Create a new pi extension based on this description: + +"${description}" +${contextSection} +## Reference documentation + +Before writing any code, read the relevant docs below. They contain the exact APIs, rules, and patterns for building pi extensions — do not guess or rely on general TypeScript knowledge alone. + +${docHints.join("\n")} + +## Output + +Write the complete implementation as a single self-contained extension file: + +\`~/.pi/agent/extensions/<kebab-case-name>.ts\` + +Then register it in the main extensions index: + +\`~/.pi/agent/extensions/index.ts\` — import and call the new extension's default export alongside existing ones + +## Rules you must follow exactly + +- Extension entry point: \`export default function <camelCaseName>(pi: ExtensionAPI): void { ... }\` +- Import type: \`import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";\` +- \`pi\` is the registration surface — call \`pi.registerCommand\`, \`pi.registerTool\`, \`pi.on\`, \`pi.registerShortcut\` inside the default export +- \`ctx\` (ExtensionCommandContext or ExtensionContext) is passed to handlers and event callbacks — never stored, never assumed available globally +- To send a message to the agent: \`pi.sendUserMessage("...")\` or \`pi.sendMessage({ content, display }, { triggerTurn })\` +- To show UI: \`ctx.ui.notify\`, \`ctx.ui.select\`, \`ctx.ui.input\`, \`ctx.ui.confirm\`, \`ctx.ui.custom\` +- To run shell commands: \`await pi.exec("cmd", ["arg1"])\` — returns \`{ stdout, stderr, exitCode }\` +- Events use \`pi.on("event_name", async (event, ctx) => { ... })\` +- No direct file I/O without \`node:fs\` — import it explicitly if needed +- Read the gotchas file before finalising: \`22-key-rules-gotchas.md\` + +After writing the files, run \`/reload\` to load the new extension.`; + + pi.sendUserMessage(prompt); +} diff --git a/src/resources/extensions/slash-commands/create-slash-command.ts b/src/resources/extensions/slash-commands/create-slash-command.ts new file mode 100644 index 000000000..9610ac85f --- /dev/null +++ b/src/resources/extensions/slash-commands/create-slash-command.ts @@ -0,0 +1,234 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { showInterviewRound, type Question, type RoundResult } from "../shared/interview-ui.js"; + +export default function createSlashCommand(pi: ExtensionAPI) { + pi.registerCommand("create-slash-command", { + description: "Generate a new slash command extension from a plain-English description", + async handler(args, ctx) { + const inlineDescription = (typeof args === "string" ? args : "").trim(); + + // ── Interview — always run, no free-text step first ─────────────────── + // + // If the user already typed a description as args, we skip the "what + // should it do?" question and go straight to the behaviour questions. + // Otherwise it's the first question in the round. + + const questions: Question[] = [ + ...(!inlineDescription + ? [ + { + id: "purpose", + header: "Purpose", + question: "What should this slash command do?", + options: [ + { + label: "Automate git workflow", + description: "Commit, branch, diff, stash — anything git-related.", + }, + { + label: "Send a crafted prompt", + description: "Build a rich context prompt and hand it to the LLM.", + }, + { + label: "Run a shell task", + description: "Execute CLI tools (npm, docker, etc.) and show the output.", + }, + { + label: "Something else", + description: "Describe it in the notes field below.", + }, + ], + } satisfies Question, + ] + : []), + { + id: "trigger", + header: "Trigger", + question: "How does this command kick off its work?", + options: [ + { + label: "Sends to agent", + description: "Builds a prompt and hands off to the LLM to do the heavy lifting.", + }, + { + label: "Runs shell commands", + description: "Executes CLI commands directly (git, npm, etc.) without an LLM turn.", + }, + { + label: "Shows a UI prompt", + description: "Pops up a select/input dialog to gather more info, then acts.", + }, + { + label: "Mixed — UI then agent", + description: "Collects some info via a dialog, then sends a crafted prompt to the LLM.", + }, + ], + }, + { + id: "output", + header: "Output", + question: "How should the command communicate results to the user?", + options: [ + { + label: "Agent response", + description: "The LLM writes the response — the command just triggers the turn.", + }, + { + label: "Notification", + description: "A brief inline notification (success/error/info) — no agent turn.", + }, + { + label: "Command output", + description: "Shows raw shell output or a formatted summary in the chat.", + }, + ], + }, + { + id: "args", + header: "Arguments", + question: "Does the command take arguments when invoked?", + options: [ + { + label: "No args needed", + description: "Called as just /command-name — gathers everything it needs at runtime.", + }, + { + label: "Optional freeform arg", + description: "User can type /command-name <something>, but it works without it too.", + }, + { + label: "Required arg", + description: "Needs a specific value typed after the name; shows usage if missing.", + }, + ], + }, + { + id: "complexity", + header: "Complexity", + question: "How complex does the implementation need to be?", + options: [ + { + label: "Simple — one action", + description: "Does one thing in a handful of lines. Easy to follow.", + }, + { + label: "Moderate — a few steps", + description: "Some branching, maybe a shell call or two, a conditional prompt.", + }, + { + label: "Complex — multi-step", + description: "Multiple async steps, error handling, state, or UI interactions.", + }, + ], + }, + ]; + + const result: RoundResult = await showInterviewRound( + questions, + { + progress: "New slash command · Context", + reviewHeadline: "Review your choices", + exitHeadline: "Cancel command creation?", + exitLabel: "cancel", + }, + ctx, + ); + + // User hit Esc with nothing answered — bail silently + if (!result.answers || Object.keys(result.answers).length === 0) { + ctx.ui.notify("Cancelled.", "info"); + return; + } + + // ── Resolve description ─────────────────────────────────────────────── + + let description = inlineDescription; + if (!description) { + const purpose = result.answers["purpose"]; + if (purpose) { + const selected = Array.isArray(purpose.selected) ? purpose.selected[0] : purpose.selected; + description = purpose.notes + ? purpose.notes // prefer their own words from the notes field + : selected; + } + } + + if (!description) { + ctx.ui.notify("No description captured — add details in the notes field next time.", "warning"); + return; + } + + // ── Build and send the enriched prompt ──────────────────────────────── + + sendPrompt(description, result, pi); + }, + }); +} + +// ─── Prompt builder ─────────────────────────────────────────────────────────── + +function formatAnswers(result: RoundResult): string { + const lines: string[] = []; + + const purpose = result.answers["purpose"]; + if (purpose?.notes) { + lines.push(`- **Command goal (user's words)**: ${purpose.notes}`); + } + + const trigger = result.answers["trigger"]; + if (trigger) { + const selected = Array.isArray(trigger.selected) ? trigger.selected[0] : trigger.selected; + lines.push(`- **Trigger pattern**: ${selected}${trigger.notes ? ` — ${trigger.notes}` : ""}`); + } + + const output = result.answers["output"]; + if (output) { + const selected = Array.isArray(output.selected) ? output.selected[0] : output.selected; + lines.push(`- **Output style**: ${selected}${output.notes ? ` — ${output.notes}` : ""}`); + } + + const argsAnswer = result.answers["args"]; + if (argsAnswer) { + const selected = Array.isArray(argsAnswer.selected) ? argsAnswer.selected[0] : argsAnswer.selected; + lines.push(`- **Arguments**: ${selected}${argsAnswer.notes ? ` — ${argsAnswer.notes}` : ""}`); + } + + const complexity = result.answers["complexity"]; + if (complexity) { + const selected = Array.isArray(complexity.selected) ? complexity.selected[0] : complexity.selected; + lines.push(`- **Complexity**: ${selected}${complexity.notes ? ` — ${complexity.notes}` : ""}`); + } + + return lines.join("\n"); +} + +function sendPrompt(description: string, result: RoundResult, pi: ExtensionAPI): void { + const contextSection = `\n## Context gathered from user\n${formatAnswers(result)}\n`; + + const prompt = `Create a new pi slash command extension based on this description: + +"${description}" +${contextSection} +Write the complete file contents for two files: + +1. \`~/.pi/agent/extensions/slash-commands/<name>.ts\` — the command implementation +2. Update \`~/.pi/agent/extensions/slash-commands/index.ts\` — import and register the new command alongside existing ones + +Rules you must follow exactly: +- Command registration: \`pi.registerCommand("name", { description, handler })\` +- Handler signature: \`async handler(args: string, ctx: ExtensionCommandContext)\` +- \`args\` is the raw string typed after the command name (may be empty) +- To send a message to the agent: \`pi.sendUserMessage("...")\` — this triggers an agent turn +- To show a quick notification without triggering a turn: \`ctx.ui.notify("...", "info" | "success" | "error")\` +- To run a shell command: \`await pi.exec("cmd", ["arg1", "arg2"])\` — returns \`{ stdout, stderr, exitCode }\` +- To show a select dialog: \`await ctx.ui.select("prompt", ["Option A", "Option B"])\` — returns the chosen string +- To show a text input dialog: \`await ctx.ui.input("prompt", "placeholder")\` — returns the string or null +- \`pi\` is captured in closure from the outer \`export default function(pi: ExtensionAPI)\` — use it freely inside the handler +- No \`ctx.session\`, no \`ctx.sendMessage\`, no \`args[]\` array — these do not exist +- Import type: \`import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";\` +- Export default: \`export default function <camelCaseName>(pi: ExtensionAPI) { ... }\` + +After writing the files, run \`/reload\` to load the new command.`; + + pi.sendUserMessage(prompt); +} diff --git a/src/resources/extensions/slash-commands/gsd-run.ts b/src/resources/extensions/slash-commands/gsd-run.ts new file mode 100644 index 000000000..6aa74d2d4 --- /dev/null +++ b/src/resources/extensions/slash-commands/gsd-run.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; + +export default function gsdRun(pi: ExtensionAPI) { + pi.registerCommand("gsd-run", { + description: "Read GSD-WORKFLOW.md and execute — lightweight protocol-driven GSD", + async handler(args: string, ctx: ExtensionCommandContext) { + const workflowPath = join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + + let workflow: string; + try { + workflow = readFileSync(workflowPath, "utf-8"); + } catch { + ctx.ui.notify(`Cannot read ${workflowPath}`, "error"); + return; + } + + const userNote = (typeof args === "string" ? args : "").trim(); + const noteSection = userNote + ? `\n\n## User Note\n\n${userNote}\n` + : ""; + + pi.sendMessage( + { + customType: "gsd-run", + content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}${noteSection}`, + display: false, + }, + { triggerTurn: true }, + ); + }, + }); +} diff --git a/src/resources/extensions/slash-commands/index.ts b/src/resources/extensions/slash-commands/index.ts new file mode 100644 index 000000000..6ea3c683a --- /dev/null +++ b/src/resources/extensions/slash-commands/index.ts @@ -0,0 +1,12 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import createSlashCommand from "./create-slash-command.js"; +import createExtension from "./create-extension.js"; +import auditCommand from "./audit.js"; +import gsdRun from "./gsd-run.js"; + +export default function slashCommands(pi: ExtensionAPI) { + createSlashCommand(pi); + createExtension(pi); + auditCommand(pi); + gsdRun(pi); +} diff --git a/src/resources/extensions/subagent/agents.ts b/src/resources/extensions/subagent/agents.ts new file mode 100644 index 000000000..2ae320342 --- /dev/null +++ b/src/resources/extensions/subagent/agents.ts @@ -0,0 +1,126 @@ +/** + * Agent discovery and configuration + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent"; + +export type AgentScope = "user" | "project" | "both"; + +export interface AgentConfig { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: "user" | "project"; + filePath: string; +} + +export interface AgentDiscoveryResult { + agents: AgentConfig[]; + projectAgentsDir: string | null; +} + +function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { + const agents: AgentConfig[] = []; + + if (!fs.existsSync(dir)) { + return agents; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return agents; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + + const filePath = path.join(dir, entry.name); + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content); + + if (!frontmatter.name || !frontmatter.description) { + continue; + } + + const tools = frontmatter.tools + ?.split(",") + .map((t: string) => t.trim()) + .filter(Boolean); + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + systemPrompt: body, + source, + filePath, + }); + } + + return agents; +} + +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +function findNearestProjectAgentsDir(cwd: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, ".pi", "agents"); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { + const userDir = path.join(getAgentDir(), "agents"); + const projectAgentsDir = findNearestProjectAgentsDir(cwd); + + const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user"); + const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project"); + + const agentMap = new Map<string, AgentConfig>(); + + if (scope === "both") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } else if (scope === "user") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + } else { + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + + return { agents: Array.from(agentMap.values()), projectAgentsDir }; +} + +export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } { + if (agents.length === 0) return { text: "none", remaining: 0 }; + const listed = agents.slice(0, maxItems); + const remaining = agents.length - listed.length; + return { + text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "), + remaining, + }; +} diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts new file mode 100644 index 000000000..0c4156511 --- /dev/null +++ b/src/resources/extensions/subagent/index.ts @@ -0,0 +1,1021 @@ +/** + * Subagent Tool - Delegate tasks to specialized agents + * + * Spawns a separate `pi` process for each subagent invocation, + * giving it an isolated context window. + * + * Supports three modes: + * - Single: { agent: "name", task: "..." } + * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] } + * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] } + * + * Uses JSON mode to capture structured output from subagents. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const COLLAPSED_ITEM_COUNT = 10; + +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + return `${(count / 1000000).toFixed(1)}M`; +} + +function formatUsageStats( + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens?: number; + turns?: number; + }, + model?: string, +): string { + const parts: string[] = []; + if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); + if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); + if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); + if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); + if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); + if (usage.contextTokens && usage.contextTokens > 0) { + parts.push(`ctx:${formatTokens(usage.contextTokens)}`); + } + if (model) parts.push(model); + return parts.join(" "); +} + +function formatToolCall( + toolName: string, + args: Record<string, unknown>, + themeFg: (color: any, text: string) => string, +): string { + const shortenPath = (p: string) => { + const home = os.homedir(); + return p.startsWith(home) ? `~${p.slice(home.length)}` : p; + }; + + switch (toolName) { + case "bash": { + const command = (args.command as string) || "..."; + const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command; + return themeFg("muted", "$ ") + themeFg("toolOutput", preview); + } + case "read": { + const rawPath = (args.file_path || args.path || "...") as string; + const filePath = shortenPath(rawPath); + const offset = args.offset as number | undefined; + const limit = args.limit as number | undefined; + let text = themeFg("accent", filePath); + if (offset !== undefined || limit !== undefined) { + const startLine = offset ?? 1; + const endLine = limit !== undefined ? startLine + limit - 1 : ""; + text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + } + return themeFg("muted", "read ") + text; + } + case "write": { + const rawPath = (args.file_path || args.path || "...") as string; + const filePath = shortenPath(rawPath); + const content = (args.content || "") as string; + const lines = content.split("\n").length; + let text = themeFg("muted", "write ") + themeFg("accent", filePath); + if (lines > 1) text += themeFg("dim", ` (${lines} lines)`); + return text; + } + case "edit": { + const rawPath = (args.file_path || args.path || "...") as string; + return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath)); + } + case "ls": { + const rawPath = (args.path || ".") as string; + return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath)); + } + case "find": { + const pattern = (args.pattern || "*") as string; + const rawPath = (args.path || ".") as string; + return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`); + } + case "grep": { + const pattern = (args.pattern || "") as string; + const rawPath = (args.path || ".") as string; + return ( + themeFg("muted", "grep ") + + themeFg("accent", `/${pattern}/`) + + themeFg("dim", ` in ${shortenPath(rawPath)}`) + ); + } + default: { + const argsStr = JSON.stringify(args); + const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr; + return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`); + } + } +} + +interface UsageStats { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens: number; + turns: number; +} + +interface SingleResult { + agent: string; + agentSource: "user" | "project" | "unknown"; + task: string; + exitCode: number; + messages: Message[]; + stderr: string; + usage: UsageStats; + model?: string; + stopReason?: string; + errorMessage?: string; + step?: number; +} + +interface SubagentDetails { + mode: "single" | "parallel" | "chain"; + agentScope: AgentScope; + projectAgentsDir: string | null; + results: SingleResult[]; +} + +function getFinalOutput(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text") return part.text; + } + } + } + return ""; +} + +type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }; + +function getDisplayItems(messages: Message[]): DisplayItem[] { + const items: DisplayItem[] = []; + for (const msg of messages) { + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text") items.push({ type: "text", text: part.text }); + else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments }); + } + } + } + return items; +} + +async function mapWithConcurrencyLimit<TIn, TOut>( + items: TIn[], + concurrency: number, + fn: (item: TIn, index: number) => Promise<TOut>, +): Promise<TOut[]> { + if (items.length === 0) return []; + const limit = Math.max(1, Math.min(concurrency, items.length)); + const results: TOut[] = new Array(items.length); + let nextIndex = 0; + const workers = new Array(limit).fill(null).map(async () => { + while (true) { + const current = nextIndex++; + if (current >= items.length) return; + results[current] = await fn(items[current], current); + } + }); + await Promise.all(workers); + return results; +} + +function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); + const safeName = agentName.replace(/[^\w.-]+/g, "_"); + const filePath = path.join(tmpDir, `prompt-${safeName}.md`); + fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); + return { dir: tmpDir, filePath }; +} + +type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void; + +async function runSingleAgent( + defaultCwd: string, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: OnUpdateCallback | undefined, + makeDetails: (results: SingleResult[]) => SubagentDetails, +): Promise<SingleResult> { + const agent = agents.find((a) => a.name === agentName); + + if (!agent) { + const available = agents.map((a) => `"${a.name}"`).join(", ") || "none"; + return { + agent: agentName, + agentSource: "unknown", + task, + exitCode: 1, + messages: [], + stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + step, + }; + } + + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + if (agent.model) args.push("--model", agent.model); + if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); + + let tmpPromptDir: string | null = null; + let tmpPromptPath: string | null = null; + + const currentResult: SingleResult = { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + model: agent.model, + step, + }; + + const emitUpdate = () => { + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], + details: makeDetails([currentResult]), + }); + } + }; + + try { + if (agent.systemPrompt.trim()) { + const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); + tmpPromptDir = tmp.dir; + tmpPromptPath = tmp.filePath; + args.push("--append-system-prompt", tmpPromptPath); + } + + args.push(`Task: ${task}`); + let wasAborted = false; + + const exitCode = await new Promise<number>((resolve) => { + const bundledPaths = (process.env.GSD_BUNDLED_EXTENSION_PATHS ?? "").split(":").filter(Boolean); + const extensionArgs = bundledPaths.flatMap(p => ["--extension", p]); + const proc = spawn( + process.execPath, + [process.env.GSD_BIN_PATH!, ...extensionArgs, ...args], + { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }, + ); + let buffer = ""; + + const processLine = (line: string) => { + if (!line.trim()) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + + if (event.type === "message_end" && event.message) { + const msg = event.message as Message; + currentResult.messages.push(msg); + + if (msg.role === "assistant") { + currentResult.usage.turns++; + const usage = msg.usage; + if (usage) { + currentResult.usage.input += usage.input || 0; + currentResult.usage.output += usage.output || 0; + currentResult.usage.cacheRead += usage.cacheRead || 0; + currentResult.usage.cacheWrite += usage.cacheWrite || 0; + currentResult.usage.cost += usage.cost?.total || 0; + currentResult.usage.contextTokens = usage.totalTokens || 0; + } + if (!currentResult.model && msg.model) currentResult.model = msg.model; + if (msg.stopReason) currentResult.stopReason = msg.stopReason; + if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage; + } + emitUpdate(); + } + + if (event.type === "tool_result_end" && event.message) { + currentResult.messages.push(event.message as Message); + emitUpdate(); + } + }; + + proc.stdout.on("data", (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(line); + }); + + proc.stderr.on("data", (data) => { + currentResult.stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (buffer.trim()) processLine(buffer); + resolve(code ?? 0); + }); + + proc.on("error", () => { + resolve(1); + }); + + if (signal) { + const killProc = () => { + wasAborted = true; + proc.kill("SIGTERM"); + setTimeout(() => { + if (!proc.killed) proc.kill("SIGKILL"); + }, 5000); + }; + if (signal.aborted) killProc(); + else signal.addEventListener("abort", killProc, { once: true }); + } + }); + + currentResult.exitCode = exitCode; + if (wasAborted) throw new Error("Subagent was aborted"); + return currentResult; + } finally { + if (tmpPromptPath) + try { + fs.unlinkSync(tmpPromptPath); + } catch { + /* ignore */ + } + if (tmpPromptDir) + try { + fs.rmdirSync(tmpPromptDir); + } catch { + /* ignore */ + } + } +} + +const TaskItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task to delegate to the agent" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), +}); + +const ChainItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), +}); + +const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { + description: 'Which agent directories to use. Default: "both" (user + project-local).', + default: "both", +}); + +const SubagentParams = Type.Object({ + agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), + task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), + tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), + chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })), + agentScope: Type.Optional(AgentScopeSchema), + confirmProjectAgents: Type.Optional( + Type.Boolean({ description: "Prompt before running project-local agents. Default: false.", default: false }), + ), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), +}); + +export default function (pi: ExtensionAPI) { + // /subagent command - list available agents + pi.registerCommand("subagent", { + description: "List available subagents", + handler: async (_args, ctx) => { + const discovery = discoverAgents(ctx.cwd, "both"); + if (discovery.agents.length === 0) { + ctx.ui.notify("No agents found. Add .md files to ~/.pi/agent/agents/ or .pi/agents/", "warning"); + return; + } + const lines = discovery.agents.map( + (a) => ` ${a.name} [${a.source}]${a.model ? ` (${a.model})` : ""}: ${a.description}`, + ); + ctx.ui.notify(`Available agents (${discovery.agents.length}):\n${lines.join("\n")}`, "info"); + }, + }); + + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: [ + "Delegate tasks to specialized subagents with isolated context windows.", + "Each subagent is a separate pi process with its own tools, model, and system prompt.", + "Modes: single ({ agent, task }), parallel ({ tasks: [{agent, task},...] }), chain ({ chain: [{agent, task},...] } with {previous} placeholder).", + "Agents are defined as .md files in ~/.pi/agent/agents/ (user) or .pi/agents/ (project).", + "Use the /subagent command to list available agents and their descriptions.", + "Use chain mode to pipeline: scout finds context, planner designs, worker implements.", + ].join(" "), + promptGuidelines: [ + "Use subagent to delegate self-contained tasks that benefit from an isolated context window.", + "Use scout agent first when you need codebase context before implementing.", + "Use chain mode for scout→planner→worker or worker→reviewer→worker pipelines.", + "Use parallel mode when tasks are independent and don't need each other's output.", + "Always check available agents with /subagent before choosing one.", + ], + parameters: SubagentParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = params.agentScope ?? "both"; + const discovery = discoverAgents(ctx.cwd, agentScope); + const agents = discovery.agents; + const confirmProjectAgents = params.confirmProjectAgents ?? false; + + const hasChain = (params.chain?.length ?? 0) > 0; + const hasTasks = (params.tasks?.length ?? 0) > 0; + const hasSingle = Boolean(params.agent && params.task); + const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle); + + const makeDetails = + (mode: "single" | "parallel" | "chain") => + (results: SingleResult[]): SubagentDetails => ({ + mode, + agentScope, + projectAgentsDir: discovery.projectAgentsDir, + results, + }); + + if (modeCount !== 1) { + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [ + { + type: "text", + text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`, + }, + ], + details: makeDetails("single")([]), + }; + } + + if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { + const requestedAgentNames = new Set<string>(); + if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); + if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); + if (params.agent) requestedAgentNames.add(params.agent); + + const projectAgentsRequested = Array.from(requestedAgentNames) + .map((name) => agents.find((a) => a.name === name)) + .filter((a): a is AgentConfig => a?.source === "project"); + + if (projectAgentsRequested.length > 0) { + const names = projectAgentsRequested.map((a) => a.name).join(", "); + const dir = discovery.projectAgentsDir ?? "(unknown)"; + const ok = await ctx.ui.confirm( + "Run project-local agents?", + `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, + ); + if (!ok) + return { + content: [{ type: "text", text: "Canceled: project-local agents not approved." }], + details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]), + }; + } + } + + if (params.chain && params.chain.length > 0) { + const results: SingleResult[] = []; + let previousOutput = ""; + + for (let i = 0; i < params.chain.length; i++) { + const step = params.chain[i]; + const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput); + + // Create update callback that includes all previous results + const chainUpdate: OnUpdateCallback | undefined = onUpdate + ? (partial) => { + // Combine completed results with current streaming result + const currentResult = partial.details?.results[0]; + if (currentResult) { + const allResults = [...results, currentResult]; + onUpdate({ + content: partial.content, + details: makeDetails("chain")(allResults), + }); + } + } + : undefined; + + const result = await runSingleAgent( + ctx.cwd, + agents, + step.agent, + taskWithContext, + step.cwd, + i + 1, + signal, + chainUpdate, + makeDetails("chain"), + ); + results.push(result); + + const isError = + result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMsg = + result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }], + details: makeDetails("chain")(results), + isError: true, + }; + } + previousOutput = getFinalOutput(result.messages); + } + return { + content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }], + details: makeDetails("chain")(results), + }; + } + + if (params.tasks && params.tasks.length > 0) { + if (params.tasks.length > MAX_PARALLEL_TASKS) + return { + content: [ + { + type: "text", + text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, + }, + ], + details: makeDetails("parallel")([]), + }; + + // Track all results for streaming updates + const allResults: SingleResult[] = new Array(params.tasks.length); + + // Initialize placeholder results + for (let i = 0; i < params.tasks.length; i++) { + allResults[i] = { + agent: params.tasks[i].agent, + agentSource: "unknown", + task: params.tasks[i].task, + exitCode: -1, // -1 = still running + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + }; + } + + const emitParallelUpdate = () => { + if (onUpdate) { + const running = allResults.filter((r) => r.exitCode === -1).length; + const done = allResults.filter((r) => r.exitCode !== -1).length; + onUpdate({ + content: [ + { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }, + ], + details: makeDetails("parallel")([...allResults]), + }); + } + }; + + const MAX_RETRIES = 1; // Retry failed tasks once + const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { + let result = await runSingleAgent( + ctx.cwd, + agents, + t.agent, + t.task, + t.cwd, + undefined, + signal, + // Per-task update callback + (partial) => { + if (partial.details?.results[0]) { + allResults[index] = partial.details.results[0]; + emitParallelUpdate(); + } + }, + makeDetails("parallel"), + ); + + // Auto-retry failed tasks (likely API rate limit or transient error) + const isFailed = result.exitCode !== 0 || (result.messages.length === 0 && !signal?.aborted); + if (isFailed && MAX_RETRIES > 0 && !signal?.aborted) { + result = await runSingleAgent( + ctx.cwd, + agents, + t.agent, + t.task, + t.cwd, + undefined, + signal, + (partial) => { + if (partial.details?.results[0]) { + allResults[index] = partial.details.results[0]; + emitParallelUpdate(); + } + }, + makeDetails("parallel"), + ); + } + + allResults[index] = result; + emitParallelUpdate(); + return result; + }); + + const successCount = results.filter((r) => r.exitCode === 0).length; + const summaries = results.map((r) => { + const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; + const output = isError + ? (r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)") + : getFinalOutput(r.messages); + const preview = output.slice(0, 200) + (output.length > 200 ? "..." : ""); + return `[${r.agent}] ${r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`}: ${preview || "(no output)"}`; + }); + return { + content: [ + { + type: "text", + text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, + }, + ], + details: makeDetails("parallel")(results), + }; + } + + if (params.agent && params.task) { + const result = await runSingleAgent( + ctx.cwd, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ); + const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMsg = + result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }], + details: makeDetails("single")([result]), + isError: true, + }; + } + return { + content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }], + details: makeDetails("single")([result]), + }; + } + + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }], + details: makeDetails("single")([]), + }; + }, + + renderCall(args, theme) { + const scope: AgentScope = args.agentScope ?? "both"; + if (args.chain && args.chain.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `chain (${args.chain.length} steps)`) + + theme.fg("muted", ` [${scope}]`); + for (let i = 0; i < Math.min(args.chain.length, 3); i++) { + const step = args.chain[i]; + // Clean up {previous} placeholder for display + const cleanTask = step.task.replace(/\{previous\}/g, "").trim(); + const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask; + text += + "\n " + + theme.fg("muted", `${i + 1}.`) + + " " + + theme.fg("accent", step.agent) + + theme.fg("dim", ` ${preview}`); + } + if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`; + return new Text(text, 0, 0); + } + if (args.tasks && args.tasks.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + + theme.fg("muted", ` [${scope}]`); + for (const t of args.tasks.slice(0, 3)) { + const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task; + text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`; + } + if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`; + return new Text(text, 0, 0); + } + const agentName = args.agent || "..."; + const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."; + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", agentName) + + theme.fg("muted", ` [${scope}]`); + text += `\n ${theme.fg("dim", preview)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as SubagentDetails | undefined; + if (!details || details.results.length === 0) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + } + + const mdTheme = getMarkdownTheme(); + + const renderDisplayItems = (items: DisplayItem[], limit?: number) => { + const toShow = limit ? items.slice(-limit) : items; + const skipped = limit && items.length > limit ? items.length - limit : 0; + let text = ""; + if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`); + for (const item of toShow) { + if (item.type === "text") { + const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n"); + text += `${theme.fg("toolOutput", preview)}\n`; + } else { + text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`; + } + } + return text.trimEnd(); + }; + + if (details.mode === "single" && details.results.length === 1) { + const r = details.results[0]; + const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; + const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + if (expanded) { + const container = new Container(); + let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`; + if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`; + container.addChild(new Text(header, 0, 0)); + if (isError && r.errorMessage) + container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0)); + container.addChild(new Text(theme.fg("dim", r.task), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0)); + if (displayItems.length === 0 && !finalOutput) { + container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0)); + } else { + for (const item of displayItems) { + if (item.type === "toolCall") + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + } + const usageStr = formatUsageStats(r.usage, r.model); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", usageStr), 0, 0)); + } + return container; + } + + let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`; + if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`; + if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`; + else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; + else { + text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`; + if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + } + const usageStr = formatUsageStats(r.usage, r.model); + if (usageStr) text += `\n${theme.fg("dim", usageStr)}`; + return new Text(text, 0, 0); + } + + const aggregateUsage = (results: SingleResult[]) => { + const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; + for (const r of results) { + total.input += r.usage.input; + total.output += r.usage.output; + total.cacheRead += r.usage.cacheRead; + total.cacheWrite += r.usage.cacheWrite; + total.cost += r.usage.cost; + total.turns += r.usage.turns; + } + return total; + }; + + if (details.mode === "chain") { + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗"); + + if (expanded) { + const container = new Container(); + container.addChild( + new Text( + icon + + " " + + theme.fg("toolTitle", theme.bold("chain ")) + + theme.fg("accent", `${successCount}/${details.results.length} steps`), + 0, + 0, + ), + ); + + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + container.addChild(new Spacer(1)); + container.addChild( + new Text( + `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`, + 0, + 0, + ), + ); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); + + // Show tool calls + for (const item of displayItems) { + if (item.type === "toolCall") { + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + } + + // Show final output as markdown + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + + const stepUsage = formatUsageStats(r.usage, r.model); + if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0)); + } + + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); + } + return container; + } + + // Collapsed view + let text = + icon + + " " + + theme.fg("toolTitle", theme.bold("chain ")) + + theme.fg("accent", `${successCount}/${details.results.length} steps`); + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`; + if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; + else text += `\n${renderDisplayItems(displayItems, 5)}`; + } + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; + text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + if (details.mode === "parallel") { + const running = details.results.filter((r) => r.exitCode === -1).length; + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const failCount = details.results.filter((r) => r.exitCode > 0).length; + const isRunning = running > 0; + const icon = isRunning + ? theme.fg("warning", "⏳") + : failCount > 0 + ? theme.fg("warning", "◐") + : theme.fg("success", "✓"); + const status = isRunning + ? `${successCount + failCount}/${details.results.length} done, ${running} running` + : `${successCount}/${details.results.length} tasks`; + + if (expanded && !isRunning) { + const container = new Container(); + container.addChild( + new Text( + `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, + 0, + 0, + ), + ); + + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + container.addChild(new Spacer(1)); + container.addChild( + new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0), + ); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); + + // Show tool calls + for (const item of displayItems) { + if (item.type === "toolCall") { + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + } + + // Show final output as markdown + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + + const taskUsage = formatUsageStats(r.usage, r.model); + if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0)); + } + + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); + } + return container; + } + + // Collapsed view (or still running) + let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`; + for (const r of details.results) { + const rIcon = + r.exitCode === -1 + ? theme.fg("warning", "⏳") + : r.exitCode === 0 + ? theme.fg("success", "✓") + : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`; + if (displayItems.length === 0) + text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`; + else text += `\n${renderDisplayItems(displayItems, 5)}`; + } + if (!isRunning) { + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; + } + if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + }, + }); +} diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts new file mode 100644 index 000000000..8ad147b91 --- /dev/null +++ b/src/tests/app-smoke.test.ts @@ -0,0 +1,364 @@ +/** + * App-level smoke tests for the gsd CLI package. + * + * Tests the glue code that IS the product: + * - app-paths resolve to ~/.gsd/ + * - loader sets all required env vars + * - resource-loader syncs bundled resources + * - wizard loadStoredEnvKeys hydrates env + * - npm pack produces a valid tarball + * - tarball installs and the `gsd` binary resolves + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { execSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const projectRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. app-paths +// ═══════════════════════════════════════════════════════════════════════════ + +test("app-paths resolve to ~/.gsd/", async () => { + const { appRoot, agentDir, sessionsDir, authFilePath } = await import("../app-paths.ts"); + const home = process.env.HOME!; + + assert.equal(appRoot, join(home, ".gsd"), "appRoot is ~/.gsd/"); + assert.equal(agentDir, join(home, ".gsd", "agent"), "agentDir is ~/.gsd/agent/"); + assert.equal(sessionsDir, join(home, ".gsd", "sessions"), "sessionsDir is ~/.gsd/sessions/"); + assert.equal(authFilePath, join(home, ".gsd", "agent", "auth.json"), "authFilePath is ~/.gsd/agent/auth.json"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. loader env vars +// ═══════════════════════════════════════════════════════════════════════════ + +test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { + // Run loader in a subprocess that prints env vars and exits before TUI starts + const script = ` + import { fileURLToPath } from 'url'; + import { dirname, resolve, join } from 'path'; + import { agentDir } from './app-paths.js'; + + const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg'); + process.env.PI_PACKAGE_DIR = pkgDir; + process.env.GSD_CODING_AGENT_DIR = agentDir; + process.env.GSD_BIN_PATH = process.argv[1]; + const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources'); + process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md'); + const exts = ['extensions/gsd/index.ts'].map(r => join(resourcesDir, r)); + process.env.GSD_BUNDLED_EXTENSION_PATHS = exts.join(':'); + + // Print for verification + console.log('PI_PACKAGE_DIR=' + process.env.PI_PACKAGE_DIR); + console.log('GSD_CODING_AGENT_DIR=' + process.env.GSD_CODING_AGENT_DIR); + console.log('GSD_BIN_PATH=' + process.env.GSD_BIN_PATH); + console.log('GSD_WORKFLOW_PATH=' + process.env.GSD_WORKFLOW_PATH); + console.log('GSD_BUNDLED_EXTENSION_PATHS=' + process.env.GSD_BUNDLED_EXTENSION_PATHS); + process.exit(0); + `; + + const tmp = mkdtempSync(join(tmpdir(), "gsd-loader-test-")); + const scriptPath = join(tmp, "check-env.ts"); + writeFileSync(scriptPath, script); + + try { + const output = execSync( + `node --experimental-strip-types -e " + process.chdir('${projectRoot}'); + await import('./src/app-paths.ts'); + " 2>&1`, + { encoding: "utf-8", cwd: projectRoot }, + ); + // If we got here without error, the import works + } catch { + // Fine — we test the logic inline below + } + + // Direct logic verification (no subprocess needed) + const { agentDir: ad } = await import("../app-paths.ts"); + assert.ok(ad.endsWith(".gsd/agent"), "agentDir ends with .gsd/agent"); + + // Verify the env var names are in loader.ts source + const loaderSrc = readFileSync(join(projectRoot, "src", "loader.ts"), "utf-8"); + assert.ok(loaderSrc.includes("PI_PACKAGE_DIR"), "loader sets PI_PACKAGE_DIR"); + assert.ok(loaderSrc.includes("GSD_CODING_AGENT_DIR"), "loader sets GSD_CODING_AGENT_DIR"); + assert.ok(loaderSrc.includes("GSD_BIN_PATH"), "loader sets GSD_BIN_PATH"); + assert.ok(loaderSrc.includes("GSD_WORKFLOW_PATH"), "loader sets GSD_WORKFLOW_PATH"); + assert.ok(loaderSrc.includes("GSD_BUNDLED_EXTENSION_PATHS"), "loader sets GSD_BUNDLED_EXTENSION_PATHS"); + + // Verify all 11 extension entry points are referenced in loader + // Loader uses join() calls like join(agentDir, 'extensions', 'gsd', 'index.ts') + // so we check for the distinguishing directory name of each extension + const extNames = [ + "'gsd'", + "'bg-shell'", + "'browser-tools'", + "'context7'", + "'search-the-web'", + "'slash-commands'", + "'subagent'", + "'worktree'", + "'plan-mode'", + "'ask-user-questions.ts'", + "'get-secrets-from-user.ts'", + ]; + for (const name of extNames) { + assert.ok(loaderSrc.includes(name), `loader references extension ${name}`); + } + + rmSync(tmp, { recursive: true, force: true }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. resource-loader syncs bundled resources +// ═══════════════════════════════════════════════════════════════════════════ + +test("initResources syncs extensions, agents, and AGENTS.md to target dir", async () => { + const { initResources } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-test-")); + const fakeAgentDir = join(tmp, "agent"); + + try { + initResources(fakeAgentDir); + + // Extensions synced + assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "gsd extension synced"); + assert.ok(existsSync(join(fakeAgentDir, "extensions", "browser-tools", "index.ts")), "browser-tools synced"); + assert.ok(existsSync(join(fakeAgentDir, "extensions", "search-the-web", "index.ts")), "search-the-web synced"); + assert.ok(existsSync(join(fakeAgentDir, "extensions", "context7", "index.ts")), "context7 synced"); + assert.ok(existsSync(join(fakeAgentDir, "extensions", "subagent", "index.ts")), "subagent synced"); + + // Agents synced + assert.ok(existsSync(join(fakeAgentDir, "agents", "scout.md")), "scout agent synced"); + + // AGENTS.md synced + assert.ok(existsSync(join(fakeAgentDir, "AGENTS.md")), "AGENTS.md synced"); + const agentsMd = readFileSync(join(fakeAgentDir, "AGENTS.md"), "utf-8"); + assert.ok(agentsMd.length > 1000, "AGENTS.md has substantial content"); + + // Idempotent: run again, no crash + initResources(fakeAgentDir); + assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "idempotent re-sync works"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. wizard loadStoredEnvKeys hydration +// ═══════════════════════════════════════════════════════════════════════════ + +test("loadStoredEnvKeys hydrates process.env from auth.json", async () => { + const { loadStoredEnvKeys } = await import("../wizard.ts"); + const { AuthStorage } = await import("@mariozechner/pi-coding-agent"); + + const tmp = mkdtempSync(join(tmpdir(), "gsd-wizard-test-")); + const authPath = join(tmp, "auth.json"); + writeFileSync(authPath, JSON.stringify({ + brave: { type: "api_key", key: "test-brave-key" }, + context7: { type: "api_key", key: "test-ctx7-key" }, + })); + + // Clear any existing env vars + const origBrave = process.env.BRAVE_API_KEY; + const origCtx7 = process.env.CONTEXT7_API_KEY; + const origJina = process.env.JINA_API_KEY; + delete process.env.BRAVE_API_KEY; + delete process.env.CONTEXT7_API_KEY; + delete process.env.JINA_API_KEY; + + try { + const auth = AuthStorage.create(authPath); + loadStoredEnvKeys(auth); + + assert.equal(process.env.BRAVE_API_KEY, "test-brave-key", "BRAVE_API_KEY hydrated"); + assert.equal(process.env.CONTEXT7_API_KEY, "test-ctx7-key", "CONTEXT7_API_KEY hydrated"); + assert.equal(process.env.JINA_API_KEY, undefined, "JINA_API_KEY not set (not in auth)"); + } finally { + // Restore original env + if (origBrave) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; + if (origCtx7) process.env.CONTEXT7_API_KEY = origCtx7; else delete process.env.CONTEXT7_API_KEY; + if (origJina) process.env.JINA_API_KEY = origJina; else delete process.env.JINA_API_KEY; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. loadStoredEnvKeys does NOT overwrite existing env vars +// ═══════════════════════════════════════════════════════════════════════════ + +test("loadStoredEnvKeys does not overwrite existing env vars", async () => { + const { loadStoredEnvKeys } = await import("../wizard.ts"); + const { AuthStorage } = await import("@mariozechner/pi-coding-agent"); + + const tmp = mkdtempSync(join(tmpdir(), "gsd-wizard-nooverwrite-")); + const authPath = join(tmp, "auth.json"); + writeFileSync(authPath, JSON.stringify({ + brave: { type: "api_key", key: "stored-key" }, + })); + + const origBrave = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "existing-env-key"; + + try { + const auth = AuthStorage.create(authPath); + loadStoredEnvKeys(auth); + + assert.equal(process.env.BRAVE_API_KEY, "existing-env-key", "existing env var not overwritten"); + } finally { + if (origBrave) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. npm pack produces valid tarball with correct file layout +// ═══════════════════════════════════════════════════════════════════════════ + +test("npm pack produces tarball with required files", async () => { + // Build first + execSync("npm run build", { cwd: projectRoot, stdio: "pipe" }); + + // Pack + const packOutput = execSync("npm pack --json 2>/dev/null", { + cwd: projectRoot, + encoding: "utf-8", + }); + const packInfo = JSON.parse(packOutput); + const tarball = packInfo[0].filename; + const tarballPath = join(projectRoot, tarball); + + assert.ok(existsSync(tarballPath), `tarball ${tarball} created`); + + try { + // List tarball contents + const contents = execSync(`tar tzf ${tarballPath}`, { encoding: "utf-8" }); + const files = contents.split("\n").filter(Boolean); + + // Critical files must be present + assert.ok(files.some(f => f.includes("dist/loader.js")), "tarball contains dist/loader.js"); + assert.ok(files.some(f => f.includes("dist/cli.js")), "tarball contains dist/cli.js"); + assert.ok(files.some(f => f.includes("dist/app-paths.js")), "tarball contains dist/app-paths.js"); + assert.ok(files.some(f => f.includes("dist/wizard.js")), "tarball contains dist/wizard.js"); + assert.ok(files.some(f => f.includes("dist/resource-loader.js")), "tarball contains dist/resource-loader.js"); + assert.ok(files.some(f => f.includes("pkg/package.json")), "tarball contains pkg/package.json"); + assert.ok(files.some(f => f.includes("src/resources/extensions/gsd/index.ts")), "tarball contains bundled gsd extension"); + assert.ok(files.some(f => f.includes("src/resources/AGENTS.md")), "tarball contains AGENTS.md"); + assert.ok(files.some(f => f.includes("scripts/postinstall.js")), "tarball contains postinstall script"); + + // pkg/package.json must have piConfig + const pkgJson = readFileSync(join(projectRoot, "pkg", "package.json"), "utf-8"); + const pkg = JSON.parse(pkgJson); + assert.equal(pkg.piConfig?.name, "gsd", "pkg/package.json piConfig.name is gsd"); + assert.equal(pkg.piConfig?.configDir, ".gsd", "pkg/package.json piConfig.configDir is .gsd"); + } finally { + // Clean up tarball + rmSync(tarballPath, { force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. npm pack → install → gsd binary resolves +// ═══════════════════════════════════════════════════════════════════════════ + +test("tarball installs and gsd binary resolves", async () => { + // Build and pack + execSync("npm run build", { cwd: projectRoot, stdio: "pipe" }); + const packOutput = execSync("npm pack --json 2>/dev/null", { + cwd: projectRoot, + encoding: "utf-8", + }); + const packInfo = JSON.parse(packOutput); + const tarball = packInfo[0].filename; + const tarballPath = join(projectRoot, tarball); + + const tmp = mkdtempSync(join(tmpdir(), "gsd-install-test-")); + + try { + // Install from tarball into a temp prefix + execSync(`npm install --prefix ${tmp} ${tarballPath} --no-save 2>&1`, { + encoding: "utf-8", + env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" }, + }); + + // Verify the gsd bin exists in the installed package + const installedBin = join(tmp, "node_modules", ".bin", "gsd"); + assert.ok(existsSync(installedBin), "gsd binary exists in node_modules/.bin/"); + + // Verify loader.js is executable (has shebang) + const installedLoader = join(tmp, "node_modules", "gsd-pi", "dist", "loader.js"); + const loaderContent = readFileSync(installedLoader, "utf-8"); + assert.ok(loaderContent.startsWith("#!/usr/bin/env node"), "loader.js has node shebang"); + + // Verify bundled resources are present + const installedGsdExt = join(tmp, "node_modules", "gsd-pi", "src", "resources", "extensions", "gsd", "index.ts"); + assert.ok(existsSync(installedGsdExt), "bundled gsd extension present in installed package"); + } finally { + rmSync(tarballPath, { force: true }); + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Launch → extensions load → no errors on stderr +// ═══════════════════════════════════════════════════════════════════════════ + +test("gsd launches and loads extensions without errors", async () => { + // Build first + execSync("npm run build", { cwd: projectRoot, stdio: "pipe" }); + + // Launch gsd with all optional keys set (skip wizard) and capture stderr. + // Kill after 5 seconds — we just need to see if extensions load. + const output = await new Promise<string>((resolve) => { + let stderr = ""; + const child = spawn("node", ["dist/loader.js"], { + cwd: projectRoot, + env: { + ...process.env, + BRAVE_API_KEY: "test", + CONTEXT7_API_KEY: "test", + JINA_API_KEY: "test", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + // Close stdin immediately so it's non-TTY + child.stdin.end(); + + // Give it 5s to start up + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, 5000); + + child.on("close", () => { + clearTimeout(timer); + resolve(stderr); + }); + }); + + // No extension load errors + assert.ok( + !output.includes("[gsd] Extension load error"), + `no extension load errors on stderr (got: ${output.slice(0, 500)})`, + ); + + // No crash / unhandled errors + assert.ok( + !output.includes("Error: Cannot find module"), + "no missing module errors", + ); + assert.ok( + !output.includes("ERR_MODULE_NOT_FOUND"), + "no ERR_MODULE_NOT_FOUND", + ); +}); diff --git a/src/wizard.ts b/src/wizard.ts new file mode 100644 index 000000000..998f7a037 --- /dev/null +++ b/src/wizard.ts @@ -0,0 +1,142 @@ +import { createInterface } from 'readline' +import type { AuthStorage } from '@mariozechner/pi-coding-agent' + +/** + * Internal helper: prompt for masked input using raw mode stdin. + * Handles backspace, Ctrl+C, and Enter. + * Falls back to plain readline if setRawMode is unavailable (e.g. some SSH contexts). + */ +async function promptMasked(question: string): Promise<string> { + return new Promise((resolve) => { + try { + process.stdout.write(question) + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf8') + let value = '' + const handler = (ch: string) => { + if (ch === '\r' || ch === '\n') { + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.off('data', handler) + process.stdout.write('\n') + resolve(value) + } else if (ch === '\u0003') { + // Ctrl+C — restore raw mode and exit cleanly + process.stdin.setRawMode(false) + process.stdout.write('\n') + process.exit(0) + } else if (ch === '\u007f') { + // Backspace + if (value.length > 0) { + value = value.slice(0, -1) + } + process.stdout.clearLine(0) + process.stdout.cursorTo(0) + process.stdout.write(question + '*'.repeat(value.length)) + } else { + value += ch + process.stdout.write('*') + } + } + process.stdin.on('data', handler) + } catch (_err) { + // setRawMode not available — fall back to plain readline + process.stdout.write(' (note: input will be visible)\n') + const rl = createInterface({ input: process.stdin, output: process.stdout }) + rl.question(question, (answer) => { + rl.close() + resolve(answer) + }) + } + }) +} + +/** + * Hydrate process.env from stored auth.json credentials for optional tool keys. + * Runs on every launch so extensions see Brave/Context7/Jina keys stored via the + * wizard on prior launches. + */ +export function loadStoredEnvKeys(authStorage: AuthStorage): void { + const providers: Array<[string, string]> = [ + ['brave', 'BRAVE_API_KEY'], + ['context7', 'CONTEXT7_API_KEY'], + ['jina', 'JINA_API_KEY'], + ] + for (const [provider, envVar] of providers) { + if (!process.env[envVar]) { + const cred = authStorage.get(provider) + if (cred?.type === 'api_key') { + process.env[envVar] = cred.key as string + } + } + } +} + +/** + * Check for missing optional tool API keys and prompt for them if on a TTY. + * + * Anthropic auth is handled by pi's own OAuth/API key flow — we don't touch it. + * This wizard only collects Brave Search, Context7, and Jina keys which are needed + * for web search and documentation tools. + * + * Behavior: + * - All optional keys present (env or auth.json): return silently + * - Non-TTY with missing optional keys: warn to stderr and continue (non-fatal) + * - TTY with missing optional keys: interactive prompts, skip on empty input + */ +export async function runWizardIfNeeded(authStorage: AuthStorage): Promise<void> { + const needsBrave = !authStorage.has('brave') && !process.env.BRAVE_API_KEY + const needsContext7 = !authStorage.has('context7') && !process.env.CONTEXT7_API_KEY + const needsJina = !authStorage.has('jina') && !process.env.JINA_API_KEY + + if (!needsBrave && !needsContext7 && !needsJina) { + return + } + + const missing = [ + needsBrave && 'Brave Search', + needsContext7 && 'Context7', + needsJina && 'Jina', + ] + .filter(Boolean) + .join(', ') + + // Non-TTY: just warn and let the session start without them + if (!process.stdin.isTTY) { + process.stderr.write( + `[gsd] Warning: optional tool API keys not configured (${missing}). Some tools may not work.\n`, + ) + return + } + + // TTY: interactive prompts for each missing key + process.stdout.write(`\n[gsd] Some optional tool API keys are not configured: ${missing}\n`) + process.stdout.write('[gsd] Press Enter to skip any key you want to set up later.\n\n') + + if (needsBrave) { + const key = await promptMasked('Brave Search API key (optional): ') + if (key) { + authStorage.set('brave', { type: 'api_key', key }) + process.env.BRAVE_API_KEY = key + } + } + + if (needsContext7) { + const key = await promptMasked('Context7 API key (optional): ') + if (key) { + authStorage.set('context7', { type: 'api_key', key }) + process.env.CONTEXT7_API_KEY = key + } + } + + if (needsJina) { + const key = await promptMasked('Jina AI API key (optional): ') + if (key) { + authStorage.set('jina', { type: 'api_key', key }) + process.env.JINA_API_KEY = key + } + } + + process.stdout.write('[gsd] Keys saved. Starting...\n\n') +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..2ff21a444 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/resources", "src/tests"] +}