diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..e50fa13e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,107 @@ +name: Bug Report +description: Report a bug in GSD +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug! Please fill out the sections below so we can reproduce and fix it. + + - type: input + id: version + attributes: + label: GSD version + description: Run `gsd --version` or check `package.json` + placeholder: "e.g., 2.15.0" + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Affected area + options: + - Auto-mode / dispatch loop + - TUI / terminal display + - Planning / roadmap + - Phase execution + - Git / worktree isolation + - Hook orchestration + - State management + - AI provider integration + - CLI / commands + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + placeholder: "When I run X, Y happens instead of Z." + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug. + placeholder: | + 1. Run `gsd ...` + 2. Select option ... + 3. See error + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error output / logs + description: Paste any error messages or relevant log output. + render: shell + + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS + - Linux + - Windows + - Other + validations: + required: true + + - type: input + id: node-version + attributes: + label: Node.js version + description: Run `node --version` + placeholder: "e.g., v22.4.0" + + - type: dropdown + id: ai-provider + attributes: + label: AI provider (if relevant) + options: + - Anthropic (Claude) + - OpenRouter + - OpenAI-compatible + - Other + - N/A + + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else — screenshots, config snippets, related issues. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..ae3a26a05 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,49 @@ +## Summary + + +- + +## Motivation + + +Closes # + +## Change type + +- [ ] `feat` — New feature or capability +- [ ] `fix` — Bug fix +- [ ] `refactor` — Code restructuring (no behavior change) +- [ ] `test` — Adding or updating tests +- [ ] `docs` — Documentation only +- [ ] `chore` — Build, CI, or tooling changes + +## Scope + +- [ ] `pi-tui` — Terminal UI +- [ ] `pi-ai` — AI/LLM layer +- [ ] `pi-agent-core` — Agent orchestration +- [ ] `pi-coding-agent` — Coding agent +- [ ] `gsd extension` — GSD workflow (`src/resources/extensions/gsd/`) +- [ ] `native` — Native bindings +- [ ] `ci/build` — Workflows, scripts, config + +## Breaking changes + +- [ ] No breaking changes +- [ ] Yes — describe below: + +## Test plan + +- [ ] Unit tests added/updated (`npm run test:unit`) +- [ ] Integration tests added/updated (`npm run test:integration`) +- [ ] Manual testing — describe steps: +- [ ] No tests needed — explain why: + +## Rollback plan + +- [ ] Safe to revert (no migrations, no state changes) +- [ ] Requires steps — describe: + +## Release context + +- **Target**: diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 8fa8e849e..47f9ef8a2 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -124,6 +124,20 @@ jobs: - name: Sync platform package versions run: node native/scripts/sync-platform-versions.cjs + - name: Detect prerelease version + id: version-check + run: | + VERSION=$(node -p "require('./package.json').version") + if echo "$VERSION" | grep -q '-next\.'; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "tag_flag=--tag next" >> "$GITHUB_OUTPUT" + echo "Prerelease detected: ${VERSION} → publishing with --tag next" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "tag_flag=" >> "$GITHUB_OUTPUT" + echo "Stable release: ${VERSION} → publishing with --tag latest (default)" + fi + - name: Publish platform packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -131,7 +145,7 @@ jobs: for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do echo "Publishing @gsd-build/engine-${platform}..." cd "native/npm/${platform}" - OUTPUT=$(npm publish --access public 2>&1) && echo "$OUTPUT" || { + OUTPUT=$(npm publish --access public ${{ steps.version-check.outputs.tag_flag }} 2>&1) && echo "$OUTPUT" || { if echo "$OUTPUT" | grep -q "cannot publish over the previously published"; then echo "Already published, skipping" else @@ -183,7 +197,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # --ignore-scripts: skip prepublishOnly since we built explicitly above - OUTPUT=$(npm publish --ignore-scripts 2>&1) && echo "$OUTPUT" || { + OUTPUT=$(npm publish --ignore-scripts ${{ steps.version-check.outputs.tag_flag }} 2>&1) && echo "$OUTPUT" || { if echo "$OUTPUT" | grep -q "cannot publish over the previously published\|You cannot publish over"; then echo "Already published, skipping" else diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04d5d8564..3eb10f406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: Build run: npm run build + - name: Typecheck extensions + run: npm run typecheck:extensions + - name: Validate package is installable run: npm run validate-pack @@ -58,5 +61,8 @@ jobs: - name: Build run: npm run build + - name: Typecheck extensions + run: npm run typecheck:extensions + - name: Run unit tests run: npm run test:unit diff --git a/.gitignore b/.gitignore index cdea9257c..f0c0c11ca 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ .gsd/milestones/**/continue.md .claude/ +RELEASE-GUIDE.md *.tgz .DS_Store Thumbs.db diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index 09bdc67d8..3f398cb71 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -50,3 +50,6 @@ | D042 | M003/S04 | pattern | shouldUseWorktreeIsolation override parameter | Accept optional overridePrefs for testability | loadEffectiveGSDPreferences computes PROJECT_PREFERENCES_PATH at module load time from process.cwd(). chdir-based test fixtures cannot influence it. Override parameter enables reliable testing. | Yes — if preference loading becomes dynamic | | D043 | M003/S04 | pattern | validatePreferences exported | Export from preferences.ts for direct test access | Was module-private. Tests need to call it directly without full file-loading pipeline. No downstream consumers affected. | No | | D044 | M003/S05 | pattern | Self-heal strategy for merge failures | Detect real conflicts immediately (skip retry), retry only transient failures once | Real conflicts will fail identically on retry — wasting time. Transient failures (stale index, leftover merge state) recover after abort+reset. Fast escalation for conflicts, automatic recovery for everything else. | Yes — if retry proves useful for some conflict types | +| D045 | M004 | arch | SQLite provider strategy | Tiered chain: node:sqlite → better-sqlite3 → null | node:sqlite available on Node 22.5+ (our target), better-sqlite3 as fallback for older Node, null for graceful degradation. DbAdapter normalizes API differences. | Yes — if node:sqlite stabilizes and better-sqlite3 path can be dropped | +| D046 | M004 | arch | createWorktree sync/async for DB copy | Keep synchronous, use copyFileSync | Memory-db made createWorktree async for dynamic imports, but copyWorktreeDb is purely sync (copyFileSync). Static import + isDbAvailable() guard avoids async cascade through createAutoWorktree and auto.ts call sites. | No | +| D047 | M004 | arch | Port strategy | Adapt to current architecture, not blind merge | 145 commits divergence, auto.ts decomposed into 6 modules. Memory-db code is reference — capabilities ported into current file structure (auto-prompts.ts, auto-dispatch.ts, etc.), not cherry-picked. | No | diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 8c492d555..934fcb61c 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -2,7 +2,7 @@ ## What This Is -A pi coding agent extension (GSD — "Get Stuff Done") that provides structured planning, auto-mode execution, and project management for autonomous coding sessions. Includes proactive secret management, browser automation tools for UI verification, and worktree-isolated git architecture for zero-friction autonomous execution. +A pi coding agent extension (GSD — "Get Stuff Done") that provides structured planning, auto-mode execution, and project management for autonomous coding sessions. Includes proactive secret management, browser automation tools for UI verification, worktree-isolated git architecture for zero-friction autonomous execution, and SQLite-backed surgical context injection for token-efficient prompt assembly. ## Core Value @@ -21,11 +21,13 @@ The GSD extension is fully functional with: - Worktree-isolated git architecture: auto-worktree per milestone, --no-ff slice merges, milestone squash to main, preference-gated isolation modes, self-healing git repair, doctor git health checks, full e2e test coverage - Auto-worktree lifecycle: `auto-worktree.ts` module creates isolated worktrees per milestone (`milestone/` branches), wired into auto.ts startAuto/resume/stop with split-brain prevention - Branch-per-slice git model with squash merge to main (legacy mode, supported via `git.isolation: "branch"` preference) +- Decomposed auto-mode: `auto-prompts.ts` (prompt builders), `auto-dispatch.ts` (unit→prompt routing), `auto-recovery.ts` (timeout/crash recovery), `auto-worktree.ts` (worktree lifecycle) ## Architecture / Key Patterns - **Extension model**: pi extensions register tools, commands, hooks via `ExtensionAPI` - **State machine**: `auto.ts` drives `dispatchNextUnit()` which reads disk state and dispatches fresh sessions +- **Dispatch pipeline**: `auto-dispatch.ts` resolves phase → unit type + prompt via `resolveDispatch()`. Prompt builders live in `auto-prompts.ts`. - **Secrets gate**: `startAuto()` checks `getManifestStatus()` before first dispatch - **Disk-driven state**: `.gsd/` files are the source of truth, `STATE.md` is derived cache - **File parsing**: `files.ts` has markdown parsers for all GSD file types @@ -43,3 +45,4 @@ See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement sta - [x] M001: Proactive Secret Management — Front-loaded API key collection into planning so auto-mode runs uninterrupted (10 requirements validated) - [x] M002: Browser Tools Performance & Intelligence — Module decomposition, action pipeline optimization, sharp-based screenshots, form intelligence, intent-ranked retrieval, semantic actions, 108-test suite (12 requirements validated) - [x] M003: Worktree-Isolated Git Architecture — Auto-worktree per milestone, --no-ff slice merges, milestone squash to main, preferences + backwards compat, self-healing git repair, doctor health checks, full e2e test suite (13 requirements validated) +- [ ] M004: SQLite Context Store — Surgical context injection via SQLite-backed query layer, replacing whole-file prompt dumps with scoped DB queries for ≥30% token savings diff --git a/.gsd/REQUIREMENTS.md b/.gsd/REQUIREMENTS.md index 802d6c64e..86fabc74e 100644 --- a/.gsd/REQUIREMENTS.md +++ b/.gsd/REQUIREMENTS.md @@ -4,7 +4,148 @@ This file is the explicit capability and coverage contract for the project. ## Active -(No active requirements — all M003 requirements validated.) +### R045 — SQLite DB layer with tiered provider chain +- Class: core-capability +- Status: active +- Description: A SQLite abstraction layer that tries `node:sqlite` (Node 22.5+), falls back to `better-sqlite3`, then to null. A thin `DbAdapter` interface normalizes API differences. Schema init creates decisions, requirements, artifacts tables plus filtered views. WAL mode on file-backed databases. +- Why it matters: The foundation for surgical context injection. Without a queryable store, prompts must dump entire files. +- Source: execution (memory-db port) +- Primary owning slice: M004/S01 +- Supporting slices: none +- Validation: unmapped +- Notes: Port from memory-db worktree `gsd-db.ts`. Tiered provider chain proven on Node 22.20.0. `node:sqlite` returns null-prototype rows — DbAdapter normalizes via spread. + +### R046 — Graceful degradation when SQLite unavailable +- Class: continuity +- Status: active +- Description: When no SQLite provider loads, all query functions return empty results and all prompt builders fall back to `inlineGsdRootFile` filesystem loading. No crash, no visible error. +- Why it matters: SQLite must be optional. Users on exotic platforms or old Node versions must not be blocked. +- Source: execution (memory-db port) +- Primary owning slice: M004/S01 +- Supporting slices: M004/S03 +- Validation: unmapped +- Notes: Every query function guards with `isDbAvailable()` + try/catch. Every prompt builder falls back to existing `inlineGsdRootFile`. + +### R047 — Auto-migration from markdown to DB on first run +- Class: core-capability +- Status: active +- Description: When auto-mode starts on a project with `.gsd/` markdown files but no `gsd.db`, silently import all artifact types into a fresh DB. Idempotent — safe to re-run. +- Why it matters: Existing projects must transparently gain DB benefits without manual migration. +- Source: execution (memory-db port) +- Primary owning slice: M004/S02 +- Supporting slices: M004/S01 +- Validation: unmapped +- Notes: Port from memory-db `md-importer.ts`. Custom parsers for DECISIONS.md pipe-table format and REQUIREMENTS.md section/bullet format. Hierarchy walker for milestones → slices → tasks. + +### R048 — Round-trip fidelity for all artifact types +- Class: quality-attribute +- Status: active +- Description: Importing markdown into DB and regenerating markdown produces field-identical output. No data loss, no format drift. +- Why it matters: Dual-write means DB→markdown generation must be faithful. Format drift corrupts the human-readable artifacts. +- Source: execution (memory-db port) +- Primary owning slice: M004/S02 +- Supporting slices: M004/S06 +- Validation: unmapped +- Notes: Port from memory-db. Custom parsers and generators must produce/consume identical formats. + +### R049 — Surgical prompt injection via DB queries +- Class: core-capability +- Status: active +- Description: All prompt builders in `auto-prompts.ts` use scoped DB queries instead of whole-file `inlineGsdRootFile` for decisions, requirements, and project context. Decisions filtered by milestone, requirements filtered by slice ownership. +- Why it matters: This is the core value — smaller, more relevant prompts mean better agent reasoning and fewer wasted tokens. +- Source: user +- Primary owning slice: M004/S03 +- Supporting slices: M004/S01, M004/S02 +- Validation: unmapped +- Notes: Port from memory-db DB-aware helpers. Must be rewired into current `auto-prompts.ts` (not the old monolithic auto.ts). 19 `inlineGsdRootFile` calls to replace across 11 prompt builders. + +### R050 — Dual-write keeping markdown and DB in sync +- Class: continuity +- Status: active +- Description: After each dispatch unit completes and auto-commits, re-import modified markdown files into the DB. Structured LLM tools write to DB first, then regenerate markdown. Both directions stay synchronized. +- Why it matters: Markdown files are the human-readable source of truth. The DB is the query index. They must agree. +- Source: execution (memory-db port) +- Primary owning slice: M004/S03 +- Supporting slices: M004/S06 +- Validation: unmapped +- Notes: Re-import in `handleAgentEnd` after auto-commit. DB-first write in structured tools triggers markdown generation. + +### R051 — Token measurement with before/after comparison +- Class: operability +- Status: active +- Description: `promptCharCount` and `baselineCharCount` fields added to `UnitMetrics`. Measurement wired into all `snapshotUnitMetrics` call sites. Baseline = full markdown content. Prompt = DB-scoped content. Difference = token savings. +- Why it matters: Proves the ≥30% savings claim with real data. Enables ongoing monitoring of prompt efficiency. +- Source: execution (memory-db port) +- Primary owning slice: M004/S04 +- Supporting slices: M004/S03 +- Validation: unmapped +- Notes: Port from memory-db. Module-scoped measurement vars reset at top of `dispatchNextUnit`. + +### R052 — DB-first state derivation with filesystem fallback +- Class: core-capability +- Status: active +- Description: `deriveState()` queries the artifacts table for file content when DB is available, replacing the batch file-parse step. File discovery still uses disk. Falls back to filesystem when DB unavailable. +- Why it matters: Faster state derivation on large projects. Consistent with DB-first architecture. +- Source: execution (memory-db port) +- Primary owning slice: M004/S04 +- Supporting slices: M004/S01, M004/S02 +- Validation: unmapped +- Notes: Port from memory-db. File discovery (which milestones/slices/tasks exist) stays on disk. Only content loading switches to DB. + +### R053 — Worktree DB copy on creation +- Class: integration +- Status: active +- Description: When a worktree is created, copy `gsd.db` from the source project into the worktree's `.gsd/` directory. Skip WAL/SHM files. Non-fatal on failure. +- Why it matters: Worktrees need their own DB with the project's current state. Without a copy, the worktree starts with no DB context. +- Source: execution (memory-db port) +- Primary owning slice: M004/S05 +- Supporting slices: M004/S01 +- Validation: unmapped +- Notes: Port from memory-db `copyWorktreeDb`. Keep `createWorktree` synchronous — `copyFileSync` is sufficient. Guard with `isDbAvailable()`. + +### R054 — Worktree DB merge reconciliation +- Class: integration +- Status: active +- Description: When a worktree merges back (slice or milestone), ATTACH the worktree's DB and reconcile rows: INSERT OR REPLACE in a transaction with conflict detection by content column comparison. +- Why it matters: The worktree may have added decisions, requirements, or artifacts that the main DB doesn't have. +- Source: execution (memory-db port) +- Primary owning slice: M004/S05 +- Supporting slices: M004/S01 +- Validation: unmapped +- Notes: Port from memory-db `reconcileWorktreeDb`. ATTACH/DETACH pattern with try/finally for cleanup. + +### R055 — Structured LLM tools for decisions/requirements/summaries +- Class: core-capability +- Status: active +- Description: Three tools registered: `gsd_save_decision` (auto-assigns D-numbers, writes to DB + regenerates DECISIONS.md), `gsd_update_requirement` (verifies existence, updates DB + regenerates REQUIREMENTS.md), `gsd_save_summary` (writes artifact to DB + disk). +- Why it matters: Eliminates the markdown-then-parse roundtrip. LLM writes structured data directly, guaranteeing parseable output. +- Source: execution (memory-db port) +- Primary owning slice: M004/S06 +- Supporting slices: M004/S03 +- Validation: unmapped +- Notes: Port from memory-db. DB-first write pattern: upsert → fetch all → generate markdown → write file. + +### R056 — /gsd inspect command for DB diagnostics +- Class: operability +- Status: active +- Description: A `/gsd inspect` slash command that dumps schema version, table row counts, and recent entries from each table. +- Why it matters: When things go wrong, the user needs visibility into DB state without running raw SQL. +- Source: execution (memory-db port) +- Primary owning slice: M004/S06 +- Supporting slices: M004/S01 +- Validation: unmapped +- Notes: Port from memory-db. Autocomplete for subcommands (decisions, requirements, artifacts, all). + +### R057 — ≥30% token savings on planning/research dispatches +- Class: quality-attribute +- Status: active +- Description: Surgical prompt injection delivers ≥30% fewer prompt characters compared to whole-file loading, measured on mature projects with multiple milestones, decisions, and requirements. +- Why it matters: The primary user-visible value of the entire DB architecture. If savings aren't real, the complexity isn't justified. +- Source: user +- Primary owning slice: M004/S07 +- Supporting slices: M004/S03, M004/S04 +- Validation: unmapped +- Notes: Memory-db proved: 52.2% plan-slice, 66.3% decisions-only, 32.2% research composite, 42.4% lifecycle. Must re-prove against current codebase. ## Validated @@ -516,11 +657,24 @@ This file is the explicit capability and coverage contract for the project. | R042 | core-capability | deferred | none | none | unmapped | | R043 | quality-attribute | deferred | none | none | unmapped | | R044 | anti-feature | out-of-scope | none | none | n/a | +| R045 | core-capability | active | M004/S01 | none | unmapped | +| R046 | continuity | active | M004/S01 | M004/S03 | unmapped | +| R047 | core-capability | active | M004/S02 | M004/S01 | unmapped | +| R048 | quality-attribute | active | M004/S02 | M004/S06 | unmapped | +| R049 | core-capability | active | M004/S03 | M004/S01, M004/S02 | unmapped | +| R050 | continuity | active | M004/S03 | M004/S06 | unmapped | +| R051 | operability | active | M004/S04 | M004/S03 | unmapped | +| R052 | core-capability | active | M004/S04 | M004/S01, M004/S02 | unmapped | +| R053 | integration | active | M004/S05 | M004/S01 | unmapped | +| R054 | integration | active | M004/S05 | M004/S01 | unmapped | +| R055 | core-capability | active | M004/S06 | M004/S03 | unmapped | +| R056 | operability | active | M004/S06 | M004/S01 | unmapped | +| R057 | quality-attribute | active | M004/S07 | M004/S03, M004/S04 | unmapped | ## Coverage Summary -- Active requirements: 0 -- Mapped to slices: 0 +- Active requirements: 13 +- Mapped to slices: 13 - Validated: 35 - Deferred: 5 - Out of scope: 4 diff --git a/.gsd/milestones/M004/M004-CONTEXT.md b/.gsd/milestones/M004/M004-CONTEXT.md new file mode 100644 index 000000000..651908833 --- /dev/null +++ b/.gsd/milestones/M004/M004-CONTEXT.md @@ -0,0 +1,126 @@ +# M004: SQLite Context Store — Surgical Prompt Injection + +**Gathered:** 2026-03-15 +**Status:** Ready for planning + +## Project Description + +Port the completed memory-db worktree's SQLite-backed context store into the current GSD codebase. The memory-db work (7 slices, 21 requirements validated, 293 tests) was built against a pre-v2.12.0 codebase that has since diverged significantly — 145 commits on main including auto.ts decomposition, worktree architecture overhaul, and extensive refactoring. This is a port, not a merge. + +## Why This Milestone + +The current prompt assembly dumps entire files (DECISIONS.md, REQUIREMENTS.md, PROJECT.md) into every dispatch prompt regardless of relevance. On a mature project with 40+ decisions and 30+ requirements, most of that context is irrelevant to the active slice. A SQLite query layer enables surgical injection — only the decisions scoped to this milestone, only the requirements owned by this slice. The user's emphasis: "super fast context ingestion" — the DB is the mechanism for being "very, very surgically" selective about what context each task sees. + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- Run auto-mode and see ≥30% smaller prompts with only relevant context injected +- Use `gsd_save_decision`, `gsd_update_requirement`, `gsd_save_summary` tool calls that bypass markdown parsing +- Run `/gsd inspect` to see DB state for diagnostics +- Start auto-mode on an existing project and have gsd.db appear silently with all artifacts imported + +### Entry point / environment + +- Entry point: `/gsd auto` CLI command, structured LLM tools during dispatch, `/gsd inspect` slash command +- Environment: local dev (Node 22.5+, runs in pi agent process) +- Live dependencies involved: none (SQLite is embedded, no external services) + +## Completion Class + +- Contract complete means: DB opens, queries return scoped data, prompt builders use DB queries, tests pass +- Integration complete means: full auto-mode cycle runs with DB-backed context injection, dual-write keeps markdown in sync, worktree lifecycle copies/reconciles DB +- Operational complete means: existing projects migrate transparently, graceful fallback when SQLite unavailable, token savings measured and ≥30% + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- A full auto-mode dispatch cycle (research → plan → execute → complete) produces correct prompts with scoped context from the DB +- An existing project with markdown artifacts silently migrates to DB on first run with zero data loss +- Token measurement shows ≥30% savings on planning/research units +- The system works identically (via fallback) when SQLite is unavailable +- TypeScript compiles clean, all existing tests pass, new DB test suite passes + +## Risks and Unknowns + +- `auto-prompts.ts` has 11 prompt builders with 19 `inlineGsdRootFile` calls — rewiring must preserve existing prompt structure and fallback behavior +- `handleAgentEnd` in `auto.ts` has new post-unit-hook machinery since memory-db was built — dual-write re-import must integrate without disrupting hooks/doctor/rebuildState sequence +- `worktree-manager.ts` `createWorktree` is sync on main — DB copy must work synchronously (decision: use `copyFileSync`, keep sync) +- `node:sqlite` is experimental in Node 22 — API could change, but the DbAdapter abstraction insulates against this +- Memory-db's markdown parsers for DECISIONS.md and REQUIREMENTS.md are custom (not using `files.ts`) — must verify they handle current file formats + +## Existing Codebase / Prior Art + +- `src/resources/extensions/gsd/auto-prompts.ts` — 880 lines, 11 `build*Prompt()` functions, 19 `inlineGsdRootFile` calls. This is where surgical injection happens. +- `src/resources/extensions/gsd/auto-dispatch.ts` — `resolveDispatch()` maps units to prompt builders. Imports from `auto-prompts.ts`. +- `src/resources/extensions/gsd/auto.ts` — `startAuto()`, `handleAgentEnd()`, `dispatchNextUnit()`. DB init/migration goes in startup, re-import in handleAgentEnd. +- `src/resources/extensions/gsd/state.ts` — `deriveState()` — 587 lines. DB-first content loading replaces batch file parse. +- `src/resources/extensions/gsd/metrics.ts` — `UnitMetrics` interface, `snapshotUnitMetrics()`. Add `promptCharCount`/`baselineCharCount`. +- `src/resources/extensions/gsd/worktree-manager.ts` — `createWorktree()` (sync), `mergeWorktreeToMain()`. DB copy/reconcile hooks here. +- `src/resources/extensions/gsd/index.ts` — tool registrations. 3 new structured tools. +- `src/resources/extensions/gsd/commands.ts` — slash command registration. `/gsd inspect`. +- `src/resources/extensions/gsd/types.ts` — needs Decision/Requirement interfaces. +- `.gsd/worktrees/memory-db/` — the source worktree with all memory-db implementation. Reference code lives here. + +### Memory-db source modules to port: +- `.gsd/worktrees/memory-db/src/resources/extensions/gsd/gsd-db.ts` — 750 lines, SQLite abstraction layer +- `.gsd/worktrees/memory-db/src/resources/extensions/gsd/context-store.ts` — 195 lines, query layer + formatters +- `.gsd/worktrees/memory-db/src/resources/extensions/gsd/md-importer.ts` — 526 lines, markdown parsers + migration orchestrator +- `.gsd/worktrees/memory-db/src/resources/extensions/gsd/db-writer.ts` — 337 lines, DB→markdown generators + DB-first write helpers +- `.gsd/worktrees/memory-db/src/resources/extensions/gsd/tests/` — 13 test files covering all DB capabilities + +> 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 + +- R045–R057 — all 13 active requirements map to this milestone's 7 slices + +## Scope + +### In Scope + +- SQLite DB layer with tiered provider chain (node:sqlite → better-sqlite3 → null) +- Auto-migration from markdown files to DB +- Surgical prompt injection via DB queries in all prompt builders +- Dual-write keeping markdown and DB in sync (both directions) +- Token measurement with before/after comparison in UnitMetrics +- DB-first state derivation in deriveState() +- Worktree DB copy on creation and merge reconciliation +- 3 structured LLM tools (gsd_save_decision, gsd_update_requirement, gsd_save_summary) +- /gsd inspect slash command +- Full test suite for all DB capabilities + +### Out of Scope / Non-Goals + +- Vector/embedding search on artifacts (deferred — schema supports future extension) +- DB export/dump command +- Changing file discovery in deriveState (stays on disk) +- Making createWorktree async (keep sync, use copyFileSync for DB copy) + +## Technical Constraints + +- `node:sqlite` is experimental — use DbAdapter abstraction to insulate +- `node:sqlite` returns null-prototype rows — normalize via spread in DbAdapter +- Named SQL parameters must use colon-prefix (`:id`, `:scope`) for `node:sqlite` compatibility +- `createWorktree` must remain synchronous — no async cascade +- All DB operations must be wrapped in try/catch with fallback to existing behavior +- Memory-db source code is reference — adapt to current architecture, don't copy blindly + +## Integration Points + +- `auto-prompts.ts` — replace `inlineGsdRootFile` with DB-aware helpers (scoped queries with filesystem fallback) +- `auto.ts` `startAuto()` — DB open + auto-migration before first dispatch +- `auto.ts` `handleAgentEnd()` — re-import markdown after auto-commit (after doctor + rebuildState, before dispatch) +- `metrics.ts` — extend `UnitMetrics` with measurement fields, extend `snapshotUnitMetrics` signature +- `state.ts` `deriveState()` — DB-first content loading with filesystem fallback +- `worktree-manager.ts` `createWorktree()` — sync DB copy after worktree creation +- `worktree-command.ts` / merge paths — DB reconciliation after merge +- `index.ts` — 3 new tool registrations +- `commands.ts` — `/gsd inspect` command registration +- `types.ts` — Decision/Requirement interface additions + +## Open Questions + +- Whether memory-db's custom DECISIONS.md parser handles the current format (pipe tables with supersession chains) — needs verification during S02 implementation +- Whether current `deriveState()` batch-parse logic is structurally compatible with the DB-first replacement — needs verification during S04 diff --git a/.gsd/milestones/M004/M004-META.json b/.gsd/milestones/M004/M004-META.json new file mode 100644 index 000000000..b657e9119 --- /dev/null +++ b/.gsd/milestones/M004/M004-META.json @@ -0,0 +1,3 @@ +{ + "integrationBranch": "main" +} diff --git a/.gsd/milestones/M004/M004-ROADMAP.md b/.gsd/milestones/M004/M004-ROADMAP.md new file mode 100644 index 000000000..73fce2281 --- /dev/null +++ b/.gsd/milestones/M004/M004-ROADMAP.md @@ -0,0 +1,197 @@ +# M004: SQLite Context Store — Surgical Prompt Injection + +**Vision:** Replace GSD's whole-file prompt dumps with a SQLite-backed query layer that surgically injects only the context each dispatch unit needs — delivering ≥30% token savings, eliminating context pollution, and enabling structured LLM output that bypasses fragile markdown parsing. + +## Success Criteria + +- All prompt builders use DB queries for context injection (zero direct `inlineGsdRootFile` for data artifacts in prompt builders) +- Existing GSD projects migrate silently to DB on first run with zero data loss +- Planning and research dispatch units show ≥30% fewer prompt characters on mature projects +- System works identically via fallback when SQLite unavailable — no crash, transparent degradation +- Worktree creation copies gsd.db; worktree merge reconciles rows +- LLM can write decisions/requirements/summaries via structured tool calls +- `/gsd inspect` shows DB state for debugging +- Dual-write keeps markdown files in sync with DB state in both directions +- `deriveState()` reads from DB when available, falls back to filesystem +- All existing tests continue to pass, TypeScript compiles clean + +## Key Risks / Unknowns + +- `auto-prompts.ts` has 11 prompt builders with 19 `inlineGsdRootFile` calls — rewiring is high-surface-area +- `handleAgentEnd` has new post-unit-hook/doctor/rebuildState machinery — dual-write re-import must integrate cleanly +- Memory-db's custom markdown parsers may not handle format changes since the fork point +- `node:sqlite` is experimental — API stability risk (mitigated by DbAdapter abstraction) + +## Proof Strategy + +- SQLite provider risk → retire in S01 by proving tiered chain loads and queries on target platform +- Parser/format risk → retire in S02 by round-trip testing every artifact type against current file formats +- Prompt builder rewiring risk → retire in S03 by verifying all 11 builders produce correct output with DB vs markdown +- Worktree integration risk → retire in S05 by testing copy/reconcile against current worktree architecture + +## Verification Classes + +- Contract verification: unit tests for DB layer, importers, query layer, state derivation, writer, tools. Round-trip fidelity tests for migration. +- Integration verification: prompt builders produce equivalent output with DB vs markdown. Full auto-mode cycle completes. Worktree DB copy/merge works. +- Operational verification: graceful fallback when SQLite unavailable. Token measurement reports savings ≥30%. +- UAT / human verification: user runs auto-mode on a real project and confirms output quality equivalent or better + +## Milestone Definition of Done + +This milestone is complete only when all are true: + +- All prompt builders in `auto-prompts.ts` use DB queries for context injection +- Silent auto-migration works on existing GSD projects with all artifact types +- Dual-write keeps markdown files in sync with DB state (both directions) +- Graceful fallback to markdown when SQLite unavailable +- Token measurement shows ≥30% reduction on planning/research units +- `deriveState()` derives from DB, producing identical GSDState output +- Worktree DB copy and merge reconciliation work with current worktree architecture +- Structured LLM tools registered and functional with DB-first write +- `/gsd inspect` command works +- All existing tests pass, new DB test suite passes, `npx tsc --noEmit` clean +- Success criteria re-checked against live behavior + +## Requirement Coverage + +- Covers: R045, R046, R047, R048, R049, R050, R051, R052, R053, R054, R055, R056, R057 +- Partially covers: none +- Leaves for later: none +- Orphan risks: none + +## Slices + +- [ ] **S01: DB Foundation + Schema** `risk:high` `depends:[]` + > After this: SQLite DB opens with tiered provider chain, schema inits with decisions/requirements/artifacts tables plus filtered views, typed CRUD wrappers work, graceful fallback returns empty results when SQLite unavailable. Proven by unit tests against real DB. + +- [ ] **S02: Markdown Importers + Auto-Migration** `risk:medium` `depends:[S01]` + > After this: Existing GSD project with markdown files starts up → gsd.db appears silently with all artifact types imported. Round-trip fidelity proven for every artifact type — import then regenerate produces identical output. + +- [ ] **S03: Surgical Prompt Injection + Dual-Write** `risk:high` `depends:[S01,S02]` + > After this: All 11 `build*Prompt()` functions in `auto-prompts.ts` use scoped DB queries instead of `inlineGsdRootFile`. Decisions filtered by milestone, requirements filtered by slice. Dual-write re-import in `handleAgentEnd` keeps DB in sync after each dispatch unit. Falls back to filesystem when DB unavailable. + +- [ ] **S04: Token Measurement + State Derivation** `risk:medium` `depends:[S03]` + > After this: `promptCharCount`/`baselineCharCount` in UnitMetrics, measurement wired into all `snapshotUnitMetrics` call sites. `deriveState()` reads content from DB when available. Savings ≥30% confirmed on fixture data. + +- [ ] **S05: Worktree DB Isolation** `risk:medium` `depends:[S01,S02]` + > After this: `createWorktree` copies gsd.db to new worktrees (sync, non-fatal). Merge paths reconcile worktree DB rows back via ATTACH DATABASE with conflict detection. + +- [ ] **S06: Structured LLM Tools + /gsd inspect** `risk:medium` `depends:[S03]` + > After this: LLM writes decisions/requirements/summaries via tool calls that write to DB first, then regenerate markdown. `/gsd inspect` dumps schema version, table counts, recent entries. + +- [ ] **S07: Integration Verification + Polish** `risk:low` `depends:[S03,S04,S05,S06]` + > After this: Full auto-mode lifecycle test proves all subsystems compose correctly — migration → scoped queries → formatted prompts → token savings → re-import → round-trip. Edge cases (empty projects, partial migrations, fallback mode) verified. ≥30% savings confirmed on realistic fixture data. + +## Boundary Map + +### S01 → S02 + +Produces: +- `gsd-db.ts` → `openDatabase()`, `closeDatabase()`, `initSchema()`, `migrateSchema()`, typed insert/query wrappers for decisions, requirements, artifacts tables +- `gsd-db.ts` → `isDbAvailable()` boolean, `getDbProvider()` provider name +- `gsd-db.ts` → `insertDecision()`, `insertRequirement()`, `insertArtifact()`, `upsertDecision()`, `upsertRequirement()` +- `gsd-db.ts` → `transaction()` wrapper for batch operations +- `context-store.ts` → `queryDecisions(opts?)`, `queryRequirements(opts?)`, `queryArtifact(path)`, `queryProject()` +- `context-store.ts` → `formatDecisionsForPrompt()`, `formatRequirementsForPrompt()` +- `types.ts` → `Decision`, `Requirement` interfaces +- Fallback: all query functions return empty when DB unavailable + +Consumes: +- nothing (first slice) + +### S01 → S03 + +Produces: +- Same as S01 → S02 (DB layer + query functions + formatters) +- `isDbAvailable()` for conditional DB vs markdown loading in prompt builders + +Consumes: +- nothing (first slice) + +### S01 → S05 + +Produces: +- `gsd-db.ts` → `copyWorktreeDb(srcPath, destPath)` — sync file copy +- `gsd-db.ts` → `reconcileWorktreeDb(mainDbPath, worktreeDbPath)` — ATTACH-based merge +- `openDatabase()` for opening DB at arbitrary paths + +Consumes: +- nothing (first slice) + +### S02 → S03 + +Produces: +- `md-importer.ts` → `migrateFromMarkdown(basePath)` — full project import function +- `md-importer.ts` → individual parsers for all artifact types +- Auto-migration detection and execution wired into `startAuto()` + +Consumes from S01: +- `gsd-db.ts` → `openDatabase()`, typed insert wrappers, `transaction()` +- Schema tables for all artifact types + +### S02 → S05 + +Produces: +- `md-importer.ts` → `migrateFromMarkdown()` for importing markdown into a fresh worktree DB + +Consumes from S01: +- `gsd-db.ts` → database layer + +### S03 → S04 + +Produces: +- All `build*Prompt()` functions rewired to use DB queries +- DB-aware inline helpers: `inlineDecisionsFromDb()`, `inlineRequirementsFromDb()`, `inlineProjectFromDb()` +- Dual-write re-import in `handleAgentEnd` + +Consumes from S01: +- `context-store.ts` → query functions and formatters +- `gsd-db.ts` → `isDbAvailable()` + +Consumes from S02: +- `md-importer.ts` → `migrateFromMarkdown()` for re-import after auto-commit + +### S03 → S06 + +Produces: +- `context-store.ts` → complete query layer that structured tools can use +- Dual-write infrastructure (re-import pattern) + +Consumes from S01: +- `gsd-db.ts` → typed upsert wrappers + +### S04 → S07 + +Produces: +- Token measurement in `UnitMetrics` (`promptCharCount`, `baselineCharCount`) +- `deriveState()` DB-first content loading +- Measurement infrastructure in `dispatchNextUnit` + +Consumes from S03: +- Rewired prompt builders + +### S05 → S07 + +Produces: +- `copyWorktreeDb` wired into `createWorktree` +- `reconcileWorktreeDb` wired into merge paths + +Consumes from S01: +- `gsd-db.ts` → `copyWorktreeDb()`, `reconcileWorktreeDb()`, `openDatabase()` + +Consumes from S02: +- `md-importer.ts` → `migrateFromMarkdown()` for fallback import + +### S06 → S07 + +Produces: +- 3 structured LLM tools registered: `gsd_save_decision`, `gsd_update_requirement`, `gsd_save_summary` +- `/gsd inspect` slash command with autocomplete + +Consumes from S03: +- `context-store.ts` → query layer for inspect output +- Dual-write infrastructure for tool-triggered markdown regeneration + +Consumes from S01: +- `gsd-db.ts` → `upsertDecision()`, `upsertRequirement()`, `insertArtifact()` +- `db-writer.ts` → `generateDecisionsMd()`, `generateRequirementsMd()`, DB-first write helpers diff --git a/.plans/issue-524-git2-migration.md b/.plans/issue-524-git2-migration.md new file mode 100644 index 000000000..40f2d2352 --- /dev/null +++ b/.plans/issue-524-git2-migration.md @@ -0,0 +1,282 @@ +# Issue #524: Move Git Operations to Rust via git2 Crate + +## Current State + +- **git2** crate (v0.20) already a dependency with vendored libgit2 +- **7 read-only** functions already native in `git.rs` + `native-git-bridge.ts`: + - `git_current_branch`, `git_main_branch`, `git_branch_exists` + - `git_has_merge_conflicts`, `git_working_tree_status`, `git_has_changes` + - `git_commit_count_between` +- **~73 execSync/execFileSync git calls** remain across 14 TypeScript files +- All native functions follow the same pattern: native-first with execSync fallback + +## Scope + +This plan covers **Phase 1**: migrate all remaining read operations and high-value +write operations to native git2. Push operations stay as execSync (credential +handling too complex for git2). The "Additional Rust Opportunities" (state +derivation, JSONL parser) are out of scope for this PR. + +--- + +## Phase 1: New Native Read Functions (git.rs) + +### 1.1 — `git_is_repo(path: String) -> bool` +Replaces: `git rev-parse --git-dir` (3 calls in auto.ts, guided-flow.ts, doctor.ts) +Implementation: `Repository::open(path).is_ok()` + +### 1.2 — `git_has_staged_changes(repo_path: String) -> bool` +Replaces: `git diff --cached --stat` (2 calls in git-service.ts) +Implementation: Diff index vs HEAD tree, check if delta count > 0 + +### 1.3 — `git_diff_stat(repo_path, from_ref?, to_ref?) -> GitDiffStat` +Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` (session-forensics.ts) +Returns: `{ files_changed: u32, insertions: u32, deletions: u32, summary: String }` +Implementation: Diff between two trees/index/workdir, count deltas + +### 1.4 — `git_diff_name_status(repo_path, from_ref, to_ref, pathspec?) -> Vec` +Replaces: `git diff --name-status main...branch -- .gsd/` (worktree-manager.ts, 3 calls) +Returns: `Vec<{ status: String, path: String }>` +Implementation: Tree-to-tree diff with pathspec filter + +### 1.5 — `git_diff_numstat(repo_path, from_ref, to_ref) -> Vec` +Replaces: `git diff --numstat main branch` (worktree-manager.ts, 1 call) +Returns: `Vec<{ added: u32, removed: u32, path: String }>` + +### 1.6 — `git_diff_content(repo_path, from_ref, to_ref, pathspec?, exclude?) -> String` +Replaces: `git diff main...branch -- .gsd/` and `-- . :(exclude).gsd/` (worktree-manager.ts, 2 calls) +Returns: Unified diff string + +### 1.7 — `git_log_oneline(repo_path, from_ref, to_ref) -> Vec` +Replaces: `git log --oneline main..branch` (worktree-manager.ts, 1 call) +Returns: `Vec<{ sha: String, message: String }>` + +### 1.8 — `git_worktree_list(repo_path) -> Vec` +Replaces: `git worktree list --porcelain` (worktree-manager.ts, 2 calls) +Returns: `Vec<{ path: String, branch: String, is_bare: bool }>` +Implementation: `Repository::worktrees()` + individual worktree info + +### 1.9 — `git_branch_list(repo_path, pattern?) -> Vec` +Replaces: `git branch --list milestone/*`, `git branch --list gsd/*` (doctor.ts, commands.ts, 3 calls) +Returns: Branch names matching pattern + +### 1.10 — `git_branch_list_merged(repo_path, target, pattern?) -> Vec` +Replaces: `git branch --merged main --list gsd/*` (commands.ts, 1 call) +Returns: Branch names merged into target + +### 1.11 — `git_ls_files(repo_path, pathspec) -> Vec` +Replaces: `git ls-files ""` (doctor.ts, 1 call) +Implementation: Read index, filter by pathspec + +### 1.12 — `git_for_each_ref(repo_path, prefix) -> Vec` +Replaces: `git for-each-ref refs/gsd/snapshots/ --format=%(refname)` (commands.ts, 1 call) +Implementation: `repo.references_glob(prefix/*)` + +### 1.13 — `git_conflict_files(repo_path) -> Vec` +Replaces: `git diff --name-only --diff-filter=U` (auto-worktree.ts, 1 call) +Implementation: Read index conflicts + +### 1.14 — `git_batch_info(repo_path) -> GitBatchInfo` +NEW batch function: status + branch + diff summary in ONE call +Returns: `{ branch: String, has_changes: bool, status: String, staged_count: u32, unstaged_count: u32 }` + +--- + +## Phase 2: New Native Write Functions (git.rs) + +### 2.1 — `git_init(path, branch?) -> void` +Replaces: `git init -b ` (auto.ts, guided-flow.ts, 2 calls) +Implementation: `Repository::init()` + set initial branch + +### 2.2 — `git_add_all(repo_path) -> void` +Replaces: `git add -A` (auto-worktree.ts, git-service.ts, 4 calls) +Implementation: Add all to index via `repo.index().add_all()` + +### 2.3 — `git_add_paths(repo_path, paths: Vec) -> void` +Replaces: `git add -- ` (auto-worktree.ts, git-service.ts, 3 calls) +Implementation: Add specific paths to index + +### 2.4 — `git_reset_paths(repo_path, paths: Vec) -> void` +Replaces: `git reset HEAD -- ` (git-service.ts, in loop) +Implementation: Reset index entries to HEAD for specific paths + +### 2.5 — `git_commit(repo_path, message, options?) -> String` +Replaces: `git commit -m `, `git commit --no-verify -F -` (11+ calls across files) +Returns: Commit SHA +Implementation: Write index as tree → create commit → update HEAD +Options: `{ allow_empty: bool }` + +### 2.6 — `git_checkout_branch(repo_path, branch) -> void` +Replaces: `git checkout ` (auto-worktree.ts, 1 call) +Implementation: Set HEAD + checkout tree + +### 2.7 — `git_checkout_theirs(repo_path, paths: Vec) -> void` +Replaces: `git checkout --theirs -- ` (auto-worktree.ts, in loop) +Implementation: Resolve index conflict with "theirs" strategy + +### 2.8 — `git_merge_squash(repo_path, branch) -> GitMergeResult` +Replaces: `git merge --squash ` (auto-worktree.ts, worktree-manager.ts, 3 calls) +Returns: `{ success: bool, conflicts: Vec }` +Implementation: Find merge base → merge trees → apply to index + +### 2.9 — `git_merge_abort(repo_path) -> void` +Replaces: `git merge --abort` (git-self-heal.ts, worktree-command.ts, 2 calls) +Implementation: Reset to ORIG_HEAD, clean merge state + +### 2.10 — `git_rebase_abort(repo_path) -> void` +Replaces: `git rebase --abort` (git-self-heal.ts, 1 call) + +### 2.11 — `git_reset_hard(repo_path) -> void` +Replaces: `git reset --hard HEAD` (git-self-heal.ts, 1 call) +Implementation: `repo.reset(HEAD, Hard)` + +### 2.12 — `git_branch_delete(repo_path, branch, force: bool) -> void` +Replaces: `git branch -D/-d ` (5 calls across files) +Implementation: `repo.find_branch().delete()` + +### 2.13 — `git_branch_force_reset(repo_path, branch, target) -> void` +Replaces: `git branch -f ` (worktree-manager.ts, 1 call) + +### 2.14 — `git_rm_cached(repo_path, paths: Vec, recursive: bool) -> Vec` +Replaces: `git rm --cached -r --ignore-unmatch` (git-service.ts, doctor.ts, gitignore.ts, 6 calls) +Returns: List of removed paths + +### 2.15 — `git_rm_force(repo_path, paths: Vec) -> void` +Replaces: `git rm --force -- ` (auto-worktree.ts, 1 call) + +### 2.16 — `git_worktree_add(repo_path, path, branch, create_from?) -> void` +Replaces: `git worktree add` commands (worktree-manager.ts, 2 calls) +Implementation: `repo.worktree()` API + +### 2.17 — `git_worktree_remove(repo_path, path, force: bool) -> void` +Replaces: `git worktree remove --force` (worktree-manager.ts, doctor.ts, 3 calls) + +### 2.18 — `git_worktree_prune(repo_path) -> void` +Replaces: `git worktree prune` (worktree-manager.ts, 3 calls) + +### 2.19 — `git_revert_commit(repo_path, sha, no_commit: bool) -> void` +Replaces: `git revert --no-commit ` (undo.ts, 1 call) + +### 2.20 — `git_revert_abort(repo_path) -> void` +Replaces: `git revert --abort` (undo.ts, 1 call) + +### 2.21 — `git_update_ref(repo_path, refname, target?) -> void` +Replaces: `git update-ref HEAD` and `git update-ref -d ` (git-service.ts, commands.ts, 2 calls) +When target is null/empty, deletes the ref. + +--- + +## Phase 3: TypeScript Bridge Updates (native-git-bridge.ts) + +Add bridge functions for ALL new native functions, each with: +1. Native-first implementation +2. execSync fallback for when native module unavailable +3. Proper error handling +4. Type definitions + +--- + +## Phase 4: Consumer Migration + +Update each TypeScript file to use native bridge functions: + +### 4.1 — git-service.ts +- `smartStage()` → use `nativeAddAll()` + `nativeResetPaths()` +- `commit()` → use `nativeCommit()` +- `autoCommit()` → use `nativeHasStagedChanges()` +- `createSnapshot()` → use `nativeUpdateRef()` +- Runtime file cleanup → use `nativeRmCached()` +- `runPreMergeCheck()` → use `nativeReadFile()` or keep fs.readFileSync (not git) + +### 4.2 — worktree-manager.ts +- `getMainBranch()` → use `nativeDetectMainBranch()` (already exists!) +- `createWorktree()` → use `nativeWorktreeAdd()`, `nativeBranchForceReset()` +- `listWorktrees()` → use `nativeWorktreeList()` +- `removeWorktree()` → use `nativeWorktreeRemove()`, `nativeWorktreePrune()`, `nativeBranchDelete()` +- `diffWorktreeGSD()` → use `nativeDiffNameStatus()` +- `diffWorktreeAll()` → use `nativeDiffNameStatus()` +- `diffWorktreeNumstat()` → use `nativeDiffNumstat()` +- `getWorktreeGSDDiff()` → use `nativeDiffContent()` +- `getWorktreeCodeDiff()` → use `nativeDiffContent()` +- `getWorktreeLog()` → use `nativeLogOneline()` +- `mergeWorktreeToMain()` → use `nativeMergeSquash()` + `nativeCommit()` + +### 4.3 — auto-worktree.ts +- `getCurrentBranch()` → use `nativeGetCurrentBranch()` (already exists!) +- `autoCommitDirtyState()` → use `nativeWorkingTreeStatus()` + `nativeAddAll()` + `nativeCommit()` +- `mergeMilestoneToMain()` → use native merge, checkout, commit, branch delete + +### 4.4 — auto.ts +- `git rev-parse --git-dir` → use `nativeIsRepo()` +- `git init -b` → use `nativeInit()` +- `git add -A .gsd .gitignore && git commit` → use `nativeAddPaths()` + `nativeCommit()` + +### 4.5 — auto-supervisor.ts +- `detectWorkingTreeActivity()` → use `nativeHasChanges()` (already exists!) + +### 4.6 — git-self-heal.ts +- `abortAndReset()` → use `nativeMergeAbort()` + `nativeRebaseAbort()` + `nativeResetHard()` + +### 4.7 — guided-flow.ts +- Same pattern as auto.ts for init + bootstrap + +### 4.8 — doctor.ts +- `git rev-parse --git-dir` → use `nativeIsRepo()` +- `git worktree remove --force` → use `nativeWorktreeRemove()` +- `git branch --list milestone/*` → use `nativeBranchList()` +- `git branch -D` → use `nativeBranchDelete()` +- `git ls-files` → use `nativeLsFiles()` +- `git rm --cached` → use `nativeRmCached()` +- `git branch --format...` → use `nativeBranchList()` + +### 4.9 — gitignore.ts +- `untrackRuntimeFiles()` → use `nativeRmCached()` + +### 4.10 — commands.ts +- `handleCleanupBranches()` → use `nativeBranchList()`, `nativeBranchListMerged()`, `nativeBranchDelete()` +- `handleCleanupSnapshots()` → use `nativeForEachRef()`, `nativeUpdateRef()` + +### 4.11 — undo.ts +- `git revert --no-commit` → use `nativeRevertCommit()` +- `git revert --abort` → use `nativeRevertAbort()` + +### 4.12 — session-forensics.ts +- `getGitChanges()` → use `nativeWorkingTreeStatus()` + `nativeDiffStat()` + +### 4.13 — worktree-command.ts +- `git merge --abort` → use `nativeMergeAbort()` + +--- + +## Kept as execSync (out of scope) + +- `git push ` — Credential handling too complex for git2 +- `cat package.json` — Not a git command (already just fs.readFileSync) +- `npm test` / custom commands — Not git operations + +--- + +## Implementation Order + +1. **Rust functions** (git.rs) — all read functions first, then write functions +2. **TypeScript bridge** (native-git-bridge.ts) — add all new bridge functions +3. **Consumer migration** — update each .ts file to use bridge functions +4. **Remove dead code** — delete local `runGit()` helpers from files that no longer need them +5. **Testing** — build native module, run CI, verify all operations work + +--- + +## Risk Mitigation + +- Every native function has an execSync fallback in the bridge +- Write operations are tested by existing integration tests +- git2's vendored libgit2 matches git CLI behavior for standard operations +- The `loadNative()` pattern means if ANY native function crashes, ALL functions fall back to CLI + +## Expected Impact + +- **~70 execSync calls eliminated** when native module is available +- **Zero process spawns** for git operations in the common path +- **Batch operations** (git_batch_info) reduce 3-4 calls to 1 +- **Type-safe errors** instead of parsing stderr strings +- **Consistent cross-platform** behavior via libgit2 diff --git a/.plans/native-perf-optimizations.md b/.plans/native-perf-optimizations.md new file mode 100644 index 000000000..993d89444 --- /dev/null +++ b/.plans/native-perf-optimizations.md @@ -0,0 +1,133 @@ +# Native Performance Optimizations — deriveState, JSONL, Paths, Parsing + +## Overview + +Four native Rust optimizations to eliminate hot-path bottlenecks in GSD's dispatch cycle. +Building on the existing git2 migration and native parser infrastructure. + +--- + +## 1. Native deriveState — Eliminate Frontmatter Re-serialization + +### Problem +`state.ts:134-176` — When `nativeBatchParseGsdFiles()` returns parsed files, the JS +side re-serializes frontmatter back into YAML strings so downstream parsers can re-parse +them. This is a round-trip waste: Rust parses → JS re-serializes → JS re-parses. + +### Solution +The native batch parser already returns `{ metadata: JSON, body, sections }`. +Instead of re-serializing frontmatter to YAML in JS, modify `cachedLoadFile()` to +return the raw body directly, and update downstream parsers to accept pre-parsed +metadata. This eliminates the entire lines 143-172 re-serialization loop. + +However, the parsers (`parseRoadmap`, `parseSummary`, `parsePlan`, etc.) all expect +raw markdown strings with frontmatter. Changing their signatures would be a massive +refactor. Instead: + +**Approach: Make Rust return the original file content alongside parsed data.** + +Add a new field `rawContent: String` to `ParsedGsdFile` that contains the complete +original file content. The JS batch cache stores this directly, eliminating the +re-serialization entirely. Downstream parsers get exactly what `loadFile()` would return. + +### Implementation +- **Rust** (`gsd_parser.rs`): Add `raw_content` field to `ParsedGsdFile`, populate with + the original file content read from disk. +- **TS** (`native-parser-bridge.ts`): Expose `rawContent` in `BatchParsedFile`. +- **TS** (`state.ts`): Replace the 30-line re-serialization loop with + `fileContentCache.set(absPath, f.rawContent)`. + +### Impact +Eliminates ~30 lines of JS string building per dispatch. Removes JSON.parse of metadata +that was only used to re-serialize back to YAML. + +--- + +## 2. Native JSONL Streaming Parser + +### Problem +`session-forensics.ts:68-78` — Parses JSONL by `split("\n").map(JSON.parse)` with a +10MB cap. Large session files cause OOM or slowness. + +### Solution +Add a Rust JSONL parser that streams through the file with constant memory, returning +structured data. Uses `serde_json` for parsing and handles arbitrary file sizes. + +### Implementation +- **Rust** (`gsd_parser.rs`): Add `parse_jsonl_tail(path, max_entries?)` function that: + 1. Memory-maps or streams the file from the tail + 2. Parses each line as JSON + 3. Returns the last N entries as a JSON array string +- **TS** (`native-parser-bridge.ts`): Add bridge function. +- **TS** (`session-forensics.ts`): Use native parser, fall back to JS implementation. + +### Impact +Handles arbitrary file sizes. 3-5x faster parsing on 10MB files. + +--- + +## 3. Native Directory Tree Index + +### Problem +`paths.ts:20-34` — `cachedReaddirSync()` caches per-directory, but caches are +cleared every dispatch via `invalidateAllCaches()`. Each `resolveMilestoneFile`, +`resolveSliceFile`, `resolveTaskFile` triggers separate directory reads. + +### Solution +Add a Rust function that walks the entire `.gsd/` tree once and returns a flat +file listing. The JS side builds a Map from this, making all path resolution O(1) +lookups instead of repeated `readdirSync` + regex matching. + +### Implementation +- **Rust** (`gsd_parser.rs`): The `batchParseGsdFiles` already walks the tree. + Add `scan_gsd_tree(directory)` that returns `Vec<{ path, isDir, name }>` for + ALL entries (not just .md files). +- **TS** (`native-parser-bridge.ts`): Add bridge function. +- **TS** (`paths.ts`): Add native tree cache. On first access, call native scan + and build lookup maps. `clearPathCache()` clears the native cache too. + +### Impact +Eliminates 20-50 `readdirSync` calls per dispatch. Makes `resolveDir`/`resolveFile` +O(1) lookups. + +--- + +## 4. Expand Native Markdown Parsing + +### Problem +`files.ts` parsers (`parsePlan`, `parseSummary`, `parseContinue`) still use JS regex. +Each runs ~10-20 regex patterns per file. Only `parseRoadmap` has a native implementation. + +### Solution +Add native Rust implementations for `parsePlan` and `parseSummary` — the two parsers +called most frequently during `deriveState`. `parseContinue` is called infrequently +and can stay in JS. + +### Implementation +- **Rust** (`gsd_parser.rs`): Add `parse_plan_file(content)` and `parse_summary_file(content)`. +- **TS** (`native-parser-bridge.ts`): Add bridge functions with JS fallback. +- **TS** (`files.ts`): Call native versions first, fall back to JS. + +### Impact +3-5x faster parsing per file. With ~20 files per deriveState, saves 20-40ms. + +--- + +## Implementation Order + +1. **deriveState raw content** (smallest change, biggest immediate impact) +2. **Directory tree index** (eliminates readdirSync overhead) +3. **JSONL streaming parser** (helps crash recovery path) +4. **Plan/Summary native parsers** (improves parsing throughput) + +## Files Modified + +### Rust +- `native/crates/engine/src/gsd_parser.rs` — new functions + rawContent field + +### TypeScript +- `src/resources/extensions/gsd/native-parser-bridge.ts` — new bridge functions +- `src/resources/extensions/gsd/state.ts` — simplified batch cache +- `src/resources/extensions/gsd/paths.ts` — native tree cache +- `src/resources/extensions/gsd/session-forensics.ts` — native JSONL +- `src/resources/extensions/gsd/files.ts` — native plan/summary parsers diff --git a/CHANGELOG.md b/CHANGELOG.md index b48ce6f55..8bc731198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,86 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.17.0] - 2026-03-15 + +### Added +- **Token optimization profiles** — `budget`, `balanced`, and `quality` presets that coordinate model selection, phase skipping, and context compression to reduce token usage by 40-60% on budget mode +- **Complexity-based task routing** — automatically classifies tasks as simple/standard/heavy and routes to appropriate models, with persistent learning from routing history +- **`git.commit_docs` preference** — set to `false` to keep `.gsd/` planning artifacts local-only, useful for teams where only some members use GSD + +### Changed +- Updated Ollama cloud provider model catalog + +### Fixed +- Native binary hangs in GSD auto-mode paths (#453) +- Auto-mode can be stopped from a different terminal (#586) +- Parse cache collision causing false loop detection on `complete-slice` (#583) +- Exhaustive switch handling and cleanup in Google provider (#587) + +## [2.16.0] - 2026-03-15 + +### Added +- `/gsd steer` command — hard-steer plan documents during execution without stopping the pipeline +- Native git operations via libgit2 — ~70 fewer process spawns per dispatch cycle +- Native performance optimizations for `deriveState`, JSONL parsing, and path resolution +- Default model upgraded to Opus 4.6 with 1M context variant +- PR template and bug report issue template + +### Fixed +- Auto-mode continues after guided milestone planning instead of stalling at "Milestone planned" +- Git commands no longer fail when repo path contains spaces +- Arrow key cursor updates and Shift+Enter newline insertion in TUI +- Tool API keys loaded from `auth.json` at session startup +- TypeScript errors resolved across extension, test, and async-jobs files + +### Changed +- Hot-path lookup caching and error resilience optimizations +- Extension type-checking added to CI pipeline + +## [2.15.1] - 2026-03-15 + +### Fixed +- Auto-mode worktree path resolution — prompt templates now include working directory, preventing artifacts from being written to the wrong location and causing infinite re-dispatches +- Auto-mode resource sync detection — gracefully stops when resources change mid-session instead of crashing +- Auto-mode missing import for `resolveSkillDiscoveryMode` causing crash on startup +- Auto-mode recovery hardened — checkbox verification falls through correctly, corrupt roadmaps fail verification instead of silently passing, atomic writes for completed-units.json, and task completion verified via artifacts not just file existence +- Auto-mode progress widget now refreshes from disk every 5 seconds during unit execution instead of appearing frozen +- Undo command now invalidates all caches (not just state cache), preventing stale results after undoing completed tasks + +### Changed +- CI pipeline supports prerelease publishing with `--tag next` for testing before stable release + +### Added +- Unit tests for auto-dashboard, auto-recovery, and crash-recovery modules (46 new tests) + +## [2.15.0] - 2026-03-15 + +### Added +- **8 new commands**: budget enforcement, notifications, and quality-of-life improvements (#441) +- **Preferences schema validation**: detects unknown/typo'd preference keys and surfaces warnings instead of silently ignoring them (#542) +- **Pipeline-aware prompts**: each agent phase (research, plan, execute, complete) now knows its role in the pipeline, eliminating redundant code exploration between phases (#543) +- **Research depth calibration**: three-tier system (deep/targeted/light) so agents match effort to actual complexity (#543) + +### Changed +- Auto-mode decomposed into focused modules for maintainability (#534) +- Dispatch logic extracted from if-else chain to dispatch table (#539) +- v1 migration code gated behind dynamic import — only loaded when needed (#541) +- Background shell module decomposed into focused modules +- Unified cache invalidation into single `invalidateAllCaches()` function (#545) + +### Fixed +- Executor agents now receive explicit working directory, preventing writes to main repo instead of worktree (#543) +- Merge loop and .gsd/ conflict auto-resolution in worktree model, `git.isolation` preference restored (#536) +- Arrow keys no longer insert escape sequences as text during LLM streaming (#493) +- YAML preferences parser hardened for OpenRouter model IDs with special characters (#488) +- `@` file autocomplete debounced to prevent TUI freeze on large codebases (#448) +- Auto-mode stops cleanly when dispatch gap watchdog fails (#537) +- Synchronous I/O removed from hot paths (#540) +- Silent catch blocks now capture error references for crash diagnostics (#546) +- `ctx.log` error in GSD provider recovery path fixed +- TUI resource leaks patched in loader, cancellable-loader, input, and editor components (#482) +- Hardcoded ANSI escapes replaced with chalk for consistent terminal handling (#482) + ## [2.14.4] - 2026-03-15 ### Fixed @@ -658,7 +738,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.14.4...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.17.0...HEAD +[2.17.0]: https://github.com/gsd-build/gsd-2/compare/v2.16.0...v2.17.0 +[2.16.0]: https://github.com/gsd-build/gsd-2/compare/v2.15.1...v2.16.0 +[2.15.1]: https://github.com/gsd-build/gsd-2/releases/tag/v2.15.1 +[2.15.0]: https://github.com/gsd-build/gsd-2/compare/v2.14.4...v2.15.0 [2.14.4]: https://github.com/gsd-build/gsd-2/compare/v2.14.3...v2.14.4 [2.14.3]: https://github.com/gsd-build/gsd-2/compare/v2.14.2...v2.14.3 [2.14.2]: https://github.com/gsd-build/gsd-2/compare/v2.14.1...v2.14.2 diff --git a/README.md b/README.md index f14071a0f..e9aa8173a 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro | `/gsd next` | Explicit step mode (same as bare `/gsd`) | | `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | | `/gsd stop` | Stop auto mode gracefully | +| `/gsd steer` | Hard-steer plan documents during execution | | `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | | `/gsd status` | Progress dashboard | | `/gsd queue` | Queue future milestones (safe during auto mode) | diff --git a/native/crates/engine/src/git.rs b/native/crates/engine/src/git.rs index 6012a53ae..a37d0c2ef 100644 --- a/native/crates/engine/src/git.rs +++ b/native/crates/engine/src/git.rs @@ -1,14 +1,21 @@ //! Native git operations via libgit2. //! -//! Provides fast READ-ONLY git queries for the GSD dispatch hotpath, -//! eliminating the need to spawn 25-40 `git` child processes per dispatch. +//! Provides high-performance git operations for GSD, eliminating the need +//! to spawn `git` child processes via execSync. Both read and write +//! operations are implemented natively. //! -//! WRITE operations (commit, merge, checkout, push) remain as execSync -//! calls in TypeScript — only status queries are native. +//! All functions have TypeScript fallbacks in `native-git-bridge.ts` for +//! environments where the native module is unavailable. -use git2::{Repository, StatusOptions}; +use git2::{ + build::CheckoutBuilder, BranchType, Delta, DiffOptions, IndexAddOption, MergeOptions, + ObjectType, Repository, ResetType, Sort, StatusOptions, +}; use napi::bindgen_prelude::*; use napi_derive::napi; +use std::path::Path; + +// ─── Helpers ──────────────────────────────────────────────────────────────── /// Open a git repository at the given path. fn open_repo(repo_path: &str) -> Result { @@ -20,17 +27,141 @@ fn open_repo(repo_path: &str) -> Result { }) } +/// Convert a git2 error to a napi error with context. +fn git_err(context: &str, e: git2::Error) -> Error { + Error::new(Status::GenericFailure, format!("{context}: {e}")) +} + +/// Validate that a file path stays within the repository boundary. +/// Prevents path traversal attacks via patterns like `../../etc/passwd`. +fn validate_path_within_repo(repo_path: &str, file_path: &str) -> Result { + let repo_dir = std::fs::canonicalize(repo_path).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize repo path '{repo_path}': {e}")) + })?; + let full_path = repo_dir.join(file_path); + let canonical = if full_path.exists() { + std::fs::canonicalize(&full_path).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize path '{file_path}': {e}")) + })? + } else if let Some(parent) = full_path.parent() { + if parent.exists() { + let cp = std::fs::canonicalize(parent).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize parent of '{file_path}': {e}")) + })?; + cp.join(full_path.file_name().unwrap_or_default()) + } else { + full_path.clone() + } + } else { + full_path.clone() + }; + if !canonical.starts_with(&repo_dir) { + return Err(Error::new(Status::GenericFailure, format!("Path '{file_path}' escapes repository boundary"))); + } + Ok(canonical) +} + +/// Resolve a ref string to an Oid. Supports branch names, tags, HEAD, etc. +fn resolve_ref(repo: &Repository, refspec: &str) -> Result { + repo.revparse_single(refspec) + .map(|obj| obj.id()) + .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e)) +} + +/// Get the tree for a given ref. +fn ref_tree<'a>(repo: &'a Repository, refspec: &str) -> Result> { + let obj = repo + .revparse_single(refspec) + .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e))?; + obj.peel_to_tree() + .map_err(|e| git_err(&format!("Failed to peel '{refspec}' to tree"), e)) +} + +/// Find the merge base between two refs (for three-dot diff semantics). +fn merge_base_tree<'a>( + repo: &'a Repository, + from_ref: &str, + to_ref: &str, +) -> Result> { + let from_oid = resolve_ref(repo, from_ref)?; + let to_oid = resolve_ref(repo, to_ref)?; + let base_oid = repo + .merge_base(from_oid, to_oid) + .map_err(|e| git_err("Failed to find merge base", e))?; + let base_commit = repo + .find_commit(base_oid) + .map_err(|e| git_err("Failed to find merge base commit", e))?; + base_commit + .tree() + .map_err(|e| git_err("Failed to get merge base tree", e)) +} + +// ─── NAPI Return Types ───────────────────────────────────────────────────── + +#[napi(object)] +pub struct GitDiffStat { + #[napi(js_name = "filesChanged")] + pub files_changed: u32, + pub insertions: u32, + pub deletions: u32, + pub summary: String, +} + +#[napi(object)] +pub struct GitNameStatus { + pub status: String, + pub path: String, +} + +#[napi(object)] +pub struct GitNumstat { + pub added: u32, + pub removed: u32, + pub path: String, +} + +#[napi(object)] +pub struct GitLogEntry { + pub sha: String, + pub message: String, +} + +#[napi(object)] +pub struct GitWorktreeEntry { + pub path: String, + pub branch: String, + #[napi(js_name = "isBare")] + pub is_bare: bool, +} + +#[napi(object)] +pub struct GitBatchInfo { + pub branch: String, + #[napi(js_name = "hasChanges")] + pub has_changes: bool, + pub status: String, + #[napi(js_name = "stagedCount")] + pub staged_count: u32, + #[napi(js_name = "unstagedCount")] + pub unstaged_count: u32, +} + +#[napi(object)] +pub struct GitMergeResult { + pub success: bool, + pub conflicts: Vec, +} + +// ─── Existing Read Functions (unchanged) ──────────────────────────────────── + /// Get the current branch name (HEAD symbolic ref). /// Returns None if HEAD is detached. #[napi] pub fn git_current_branch(repo_path: String) -> Result> { let repo = open_repo(&repo_path)?; - let head = repo.head().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read HEAD: {e}"), - ) - })?; + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; if head.is_branch() { Ok(head.shorthand().map(String::from)) @@ -42,14 +173,10 @@ pub fn git_current_branch(repo_path: String) -> Result> { /// Detect the main/integration branch for a repository. /// /// Resolution order: -/// 1. refs/remotes/origin/HEAD → extract branch name -/// 2. refs/heads/main exists → "main" -/// 3. refs/heads/master exists → "master" +/// 1. refs/remotes/origin/HEAD -> extract branch name +/// 2. refs/heads/main exists -> "main" +/// 3. refs/heads/master exists -> "master" /// 4. Fall back to current branch -/// -/// Note: milestone integration branch and worktree detection are handled -/// in TypeScript — this function covers the repo-level default detection -/// that previously spawned 4 `git show-ref` / `git symbolic-ref` calls. #[napi] pub fn git_main_branch(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; @@ -65,24 +192,17 @@ pub fn git_main_branch(repo_path: String) -> Result { } } - // Check refs/heads/main if repo.find_reference("refs/heads/main").is_ok() { return Ok("main".to_string()); } - // Check refs/heads/master if repo.find_reference("refs/heads/master").is_ok() { return Ok("master".to_string()); } - // Fall back to current branch - let head = repo.head().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read HEAD: {e}"), - ) - })?; - + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; Ok(head.shorthand().unwrap_or("HEAD").to_string()) } @@ -99,13 +219,9 @@ pub fn git_branch_exists(repo_path: String, branch: String) -> Result { #[napi] pub fn git_has_merge_conflicts(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; - let index = repo.index().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read index: {e}"), - ) - })?; - + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; Ok(index.has_conflicts()) } @@ -115,15 +231,11 @@ pub fn git_has_merge_conflicts(repo_path: String) -> Result { pub fn git_working_tree_status(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; let mut opts = StatusOptions::new(); - opts.include_untracked(true) - .recurse_untracked_dirs(true); + opts.include_untracked(true).recurse_untracked_dirs(true); - let statuses = repo.statuses(Some(&mut opts)).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to get status: {e}"), - ) - })?; + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; let mut lines = Vec::with_capacity(statuses.len()); for entry in statuses.iter() { @@ -171,12 +283,9 @@ pub fn git_has_changes(repo_path: String) -> Result { let mut opts = StatusOptions::new(); opts.include_untracked(true); - let statuses = repo.statuses(Some(&mut opts)).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to get status: {e}"), - ) - })?; + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; Ok(!statuses.is_empty()) } @@ -190,47 +299,1378 @@ pub fn git_commit_count_between( ) -> Result { let repo = open_repo(&repo_path)?; - let from_oid = repo - .revparse_single(&from_ref) - .map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to resolve ref '{from_ref}': {e}"), - ) - })? - .id(); + let from_oid = resolve_ref(&repo, &from_ref)?; + let to_oid = resolve_ref(&repo, &to_ref)?; - let to_oid = repo - .revparse_single(&to_ref) - .map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to resolve ref '{to_ref}': {e}"), - ) - })? - .id(); + let mut revwalk = repo + .revwalk() + .map_err(|e| git_err("Failed to create revwalk", e))?; - let mut revwalk = repo.revwalk().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to create revwalk: {e}"), - ) - })?; + revwalk + .push(to_oid) + .map_err(|e| git_err("Failed to push to_ref", e))?; + revwalk + .hide(from_oid) + .map_err(|e| git_err("Failed to hide from_ref", e))?; - revwalk.push(to_oid).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to push to_ref: {e}"), - ) - })?; - - revwalk.hide(from_oid).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to hide from_ref: {e}"), - ) - })?; - - let count = revwalk.count() as u32; - Ok(count) + Ok(revwalk.count() as u32) +} + +// ─── New Read Functions ───────────────────────────────────────────────────── + +/// Check if a path is inside a git repository. +/// Replaces: `git rev-parse --git-dir` +#[napi] +pub fn git_is_repo(path: String) -> bool { + Repository::open(&path).is_ok() +} + +/// Check if there are any staged changes (index differs from HEAD). +/// Replaces: `git diff --cached --stat` check +#[napi] +pub fn git_has_staged_changes(repo_path: String) -> Result { + let repo = open_repo(&repo_path)?; + + // Get HEAD tree (may not exist for initial commit) + let head_tree = match repo.head() { + Ok(head) => { + let commit = head + .peel_to_commit() + .map_err(|e| git_err("Failed to peel HEAD to commit", e))?; + Some( + commit + .tree() + .map_err(|e| git_err("Failed to get HEAD tree", e))?, + ) + } + Err(_) => None, // No commits yet — everything in index is "staged" + }; + + let diff = repo + .diff_tree_to_index(head_tree.as_ref(), None, None) + .map_err(|e| git_err("Failed to diff tree to index", e))?; + + Ok(diff.deltas().len() > 0) +} + +/// Get diff statistics between two refs, or between HEAD and working tree. +/// When `from_ref` is "HEAD" and `to_ref` is "WORKDIR", diffs working tree vs HEAD. +/// When `from_ref` is "HEAD" and `to_ref` is "INDEX", diffs index vs HEAD (staged). +/// Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` +#[napi] +pub fn git_diff_stat( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result { + let repo = open_repo(&repo_path)?; + + let diff = match (from_ref.as_str(), to_ref.as_str()) { + ("HEAD", "WORKDIR") => { + let head_tree = match repo.head() { + Ok(head) => Some( + head.peel_to_tree() + .map_err(|e| git_err("Failed to peel HEAD to tree", e))?, + ), + Err(_) => None, + }; + repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .map_err(|e| git_err("Failed to diff", e))? + } + ("HEAD", "INDEX") => { + let head_tree = match repo.head() { + Ok(head) => Some( + head.peel_to_tree() + .map_err(|e| git_err("Failed to peel HEAD to tree", e))?, + ), + Err(_) => None, + }; + repo.diff_tree_to_index(head_tree.as_ref(), None, None) + .map_err(|e| git_err("Failed to diff", e))? + } + _ => { + let from_tree = ref_tree(&repo, &from_ref)?; + let to_tree = ref_tree(&repo, &to_ref)?; + repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) + .map_err(|e| git_err("Failed to diff", e))? + } + }; + + let stats = diff + .stats() + .map_err(|e| git_err("Failed to get diff stats", e))?; + + let summary = stats + .to_buf(git2::DiffStatsFormat::FULL, 80) + .map_err(|e| git_err("Failed to format diff stats", e))? + .as_str() + .unwrap_or("") + .to_string(); + + Ok(GitDiffStat { + files_changed: stats.files_changed() as u32, + insertions: stats.insertions() as u32, + deletions: stats.deletions() as u32, + summary, + }) +} + +/// Get name-status diff between two refs with optional pathspec filter. +/// `use_merge_base`: if true, uses three-dot semantics (diff from merge base). +/// Replaces: `git diff --name-status main...branch -- .gsd/` +#[napi] +pub fn git_diff_name_status( + repo_path: String, + from_ref: String, + to_ref: String, + pathspec: Option, + use_merge_base: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let mut diff_opts = DiffOptions::new(); + if let Some(ref ps) = pathspec { + diff_opts.pathspec(ps); + } + + let from_tree = if use_merge_base.unwrap_or(false) { + merge_base_tree(&repo, &from_ref, &to_ref)? + } else { + ref_tree(&repo, &from_ref)? + }; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts)) + .map_err(|e| git_err("Failed to diff trees", e))?; + + let mut results = Vec::with_capacity(diff.deltas().len()); + for delta in diff.deltas() { + let status_char = match delta.status() { + Delta::Added => "A", + Delta::Deleted => "D", + Delta::Modified => "M", + Delta::Renamed => "R", + Delta::Copied => "C", + Delta::Typechange => "T", + _ => continue, + }; + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + results.push(GitNameStatus { + status: status_char.to_string(), + path, + }); + } + + Ok(results) +} + +/// Get numstat diff between two refs. +/// Replaces: `git diff --numstat main branch` +#[napi] +pub fn git_diff_numstat( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let from_tree = ref_tree(&repo, &from_ref)?; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) + .map_err(|e| git_err("Failed to diff trees", e))?; + + // Collect paths per delta index, then count lines in a second pass + let mut results = Vec::new(); + for delta in diff.deltas() { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + results.push(GitNumstat { + added: 0, + removed: 0, + path, + }); + } + + // Count added/removed lines per file using the patch API + for (i, _) in diff.deltas().enumerate() { + if let Ok(patch) = git2::Patch::from_diff(&diff, i) { + if let Some(patch) = patch { + let (_, additions, deletions) = patch.line_stats() + .unwrap_or((0, 0, 0)); + if let Some(entry) = results.get_mut(i) { + entry.added = additions as u32; + entry.removed = deletions as u32; + } + } + } + } + + Ok(results) +} + +/// Get unified diff content between two refs with optional pathspec/exclude. +/// `use_merge_base`: if true, uses three-dot semantics. +/// `exclude`: optional pathspec to exclude (e.g., ".gsd/"). +/// Replaces: `git diff main...branch -- .gsd/` and `-- . :(exclude).gsd/` +#[napi] +pub fn git_diff_content( + repo_path: String, + from_ref: String, + to_ref: String, + pathspec: Option, + exclude: Option, + use_merge_base: Option, +) -> Result { + let repo = open_repo(&repo_path)?; + + let mut diff_opts = DiffOptions::new(); + if let Some(ref ps) = pathspec { + diff_opts.pathspec(ps); + } + + let from_tree = if use_merge_base.unwrap_or(false) { + merge_base_tree(&repo, &from_ref, &to_ref)? + } else { + ref_tree(&repo, &from_ref)? + }; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts)) + .map_err(|e| git_err("Failed to diff trees", e))?; + + let exclude_prefix = exclude.as_deref(); + + let mut output = String::new(); + diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { + // Apply exclude filter + if let Some(excl) = exclude_prefix { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if path.starts_with(excl) { + return true; + } + } + + let prefix = match line.origin() { + '+' | '-' | ' ' => { + output.push(line.origin()); + "" + } + 'F' | 'H' | 'B' => "", + _ => "", + }; + output.push_str(prefix); + if let Ok(content) = std::str::from_utf8(line.content()) { + output.push_str(content); + } + true + }) + .map_err(|e| git_err("Failed to print diff", e))?; + + Ok(output) +} + +/// Get commit log between two refs (from..to). +/// Replaces: `git log --oneline main..branch` +#[napi] +pub fn git_log_oneline( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let from_oid = resolve_ref(&repo, &from_ref)?; + let to_oid = resolve_ref(&repo, &to_ref)?; + + let mut revwalk = repo + .revwalk() + .map_err(|e| git_err("Failed to create revwalk", e))?; + revwalk.set_sorting(Sort::TIME).ok(); + revwalk + .push(to_oid) + .map_err(|e| git_err("Failed to push to_ref", e))?; + revwalk + .hide(from_oid) + .map_err(|e| git_err("Failed to hide from_ref", e))?; + + let mut entries = Vec::new(); + for oid in revwalk.flatten() { + if let Ok(commit) = repo.find_commit(oid) { + let sha = format!("{:.7}", oid); + let message = commit.summary().unwrap_or("").to_string(); + entries.push(GitLogEntry { sha, message }); + } + } + + Ok(entries) +} + +/// List git worktrees in porcelain format. +/// Replaces: `git worktree list --porcelain` +#[napi] +pub fn git_worktree_list(repo_path: String) -> Result> { + let repo = open_repo(&repo_path)?; + + let mut entries = Vec::new(); + + // Add the main worktree + if let Some(workdir) = repo.workdir() { + let branch = match repo.head() { + Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(), + Err(_) => "HEAD".to_string(), + }; + entries.push(GitWorktreeEntry { + path: workdir.to_string_lossy().to_string(), + branch, + is_bare: false, + }); + } else if repo.is_bare() { + entries.push(GitWorktreeEntry { + path: repo.path().to_string_lossy().to_string(), + branch: String::new(), + is_bare: true, + }); + } + + // List linked worktrees + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + let wt_path = wt.path().to_string_lossy().to_string(); + // Open the worktree's repo to read its HEAD + let branch = match Repository::open(&wt_path) { + Ok(wt_repo) => match wt_repo.head() { + Ok(head) => { + if let Some(name) = head.name() { + name.strip_prefix("refs/heads/") + .unwrap_or(head.shorthand().unwrap_or("HEAD")) + .to_string() + } else { + "HEAD".to_string() + } + } + Err(_) => "HEAD".to_string(), + }, + Err(_) => String::new(), + }; + entries.push(GitWorktreeEntry { + path: wt_path, + branch, + is_bare: false, + }); + } + } + } + + Ok(entries) +} + +/// List branches matching an optional glob pattern. +/// Replaces: `git branch --list milestone/*`, `git branch --list gsd/*` +#[napi] +pub fn git_branch_list(repo_path: String, pattern: Option) -> Result> { + let repo = open_repo(&repo_path)?; + let branches = repo + .branches(Some(BranchType::Local)) + .map_err(|e| git_err("Failed to list branches", e))?; + + let mut names = Vec::new(); + for branch_result in branches { + let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?; + if let Some(name) = branch.name().ok().flatten() { + if let Some(ref pat) = pattern { + // Simple glob matching: support "prefix/*" and "prefix/*/*" + if matches_branch_pattern(name, pat) { + names.push(name.to_string()); + } + } else { + names.push(name.to_string()); + } + } + } + + Ok(names) +} + +/// Simple branch pattern matching for patterns like "milestone/*", "gsd/*/*" +fn matches_branch_pattern(name: &str, pattern: &str) -> bool { + // Handle simple prefix/* patterns + if let Some(prefix) = pattern.strip_suffix("/*") { + // For "gsd/*/*", this becomes "gsd/*" after first strip + if prefix.contains('*') { + // Recursive: "gsd/*/*" → name must start with "gsd/" and have at least 2 segments after + if let Some(inner_prefix) = prefix.strip_suffix("/*") { + return name.starts_with(&format!("{inner_prefix}/")) + && name[inner_prefix.len() + 1..].contains('/'); + } + } + return name.starts_with(&format!("{prefix}/")); + } + // Exact match + name == pattern +} + +/// List branches that have been merged into the given target branch. +/// Replaces: `git branch --merged main --list gsd/*` +#[napi] +pub fn git_branch_list_merged( + repo_path: String, + target: String, + pattern: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + let target_oid = resolve_ref(&repo, &target)?; + + let branches = repo + .branches(Some(BranchType::Local)) + .map_err(|e| git_err("Failed to list branches", e))?; + + let mut merged = Vec::new(); + for branch_result in branches { + let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?; + if let Some(name) = branch.name().ok().flatten() { + // Apply pattern filter + if let Some(ref pat) = pattern { + if !matches_branch_pattern(name, pat) { + continue; + } + } + + // Check if merged: a branch is merged into target if the merge base + // of the branch tip and target equals the branch tip. + if let Ok(branch_ref) = branch.get().peel(ObjectType::Commit) { + let branch_oid = branch_ref.id(); + if let Ok(base) = repo.merge_base(target_oid, branch_oid) { + if base == branch_oid { + merged.push(name.to_string()); + } + } + } + } + } + + Ok(merged) +} + +/// List files tracked in the index matching a pathspec. +/// Replaces: `git ls-files ""` +#[napi] +pub fn git_ls_files(repo_path: String, pathspec: String) -> Result> { + let repo = open_repo(&repo_path)?; + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + let mut files = Vec::new(); + for entry in index.iter() { + let path = String::from_utf8_lossy(&entry.path).to_string(); + if path.starts_with(&pathspec) || (pathspec.ends_with('/') && path.starts_with(pathspec.trim_end_matches('/'))) { + files.push(path); + } + } + + Ok(files) +} + +/// List references matching a prefix. +/// Replaces: `git for-each-ref refs/gsd/snapshots/ --format=%(refname)` +#[napi] +pub fn git_for_each_ref(repo_path: String, prefix: String) -> Result> { + let repo = open_repo(&repo_path)?; + let glob = if prefix.ends_with('/') { + format!("{prefix}*") + } else { + format!("{prefix}/*") + }; + + let refs = repo + .references_glob(&glob) + .map_err(|e| git_err("Failed to list references", e))?; + + let mut names = Vec::new(); + for r in refs.flatten() { + if let Some(name) = r.name() { + names.push(name.to_string()); + } + } + + Ok(names) +} + +/// Get list of files with unmerged (conflict) entries in the index. +/// Replaces: `git diff --name-only --diff-filter=U` +#[napi] +pub fn git_conflict_files(repo_path: String) -> Result> { + let repo = open_repo(&repo_path)?; + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + if !index.has_conflicts() { + return Ok(Vec::new()); + } + + let conflicts = index + .conflicts() + .map_err(|e| git_err("Failed to read conflicts", e))?; + + let mut files = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for conflict in conflicts.flatten() { + // A conflict has ancestor, our, theirs entries — get the path from whichever exists + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .map(|entry| String::from_utf8_lossy(&entry.path).to_string()); + + if let Some(p) = path { + if seen.insert(p.clone()) { + files.push(p); + } + } + } + + Ok(files) +} + +/// Get batch info: branch + status + change counts in ONE call. +/// Replaces: sequential calls to getCurrentBranch + hasChanges + status. +#[napi] +pub fn git_batch_info(repo_path: String) -> Result { + let repo = open_repo(&repo_path)?; + + // Branch + let branch = match repo.head() { + Ok(head) => { + if head.is_branch() { + head.shorthand().unwrap_or("HEAD").to_string() + } else { + "HEAD".to_string() + } + } + Err(_) => String::new(), + }; + + // Status + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; + + let has_changes = !statuses.is_empty(); + let mut staged_count: u32 = 0; + let mut unstaged_count: u32 = 0; + let mut lines = Vec::with_capacity(statuses.len()); + + for entry in statuses.iter() { + let status = entry.status(); + let path = entry.path().unwrap_or("?"); + + let index_char = if status.is_index_new() { + staged_count += 1; + 'A' + } else if status.is_index_modified() { + staged_count += 1; + 'M' + } else if status.is_index_deleted() { + staged_count += 1; + 'D' + } else if status.is_index_renamed() { + staged_count += 1; + 'R' + } else if status.is_index_typechange() { + staged_count += 1; + 'T' + } else { + ' ' + }; + + let wt_char = if status.is_wt_new() { + unstaged_count += 1; + '?' + } else if status.is_wt_modified() { + unstaged_count += 1; + 'M' + } else if status.is_wt_deleted() { + unstaged_count += 1; + 'D' + } else if status.is_wt_renamed() { + unstaged_count += 1; + 'R' + } else if status.is_wt_typechange() { + unstaged_count += 1; + 'T' + } else { + ' ' + }; + + lines.push(format!("{index_char}{wt_char} {path}")); + } + + Ok(GitBatchInfo { + branch, + has_changes, + status: lines.join("\n"), + staged_count, + unstaged_count, + }) +} + +// ─── Write Functions ──────────────────────────────────────────────────────── + +/// Initialize a new git repository. +/// Replaces: `git init -b ` +#[napi] +pub fn git_init(path: String, initial_branch: Option) -> Result<()> { + let repo = Repository::init(&path).map_err(|e| git_err("Failed to init repository", e))?; + + // Set initial branch name if specified + if let Some(branch_name) = initial_branch { + // For a new repo, HEAD points to refs/heads/master by default. + // We need to update the symbolic ref to point to the desired branch. + repo.set_head(&format!("refs/heads/{branch_name}")) + .map_err(|e| git_err("Failed to set initial branch", e))?; + } + + Ok(()) +} + +/// Stage all files (equivalent to `git add -A`). +/// Replaces: `git add -A` +#[napi] +pub fn git_add_all(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .map_err(|e| git_err("Failed to add all files", e))?; + + // Also handle deletions: update the index to reflect removed files + index + .update_all(["*"].iter(), None) + .map_err(|e| git_err("Failed to update index for deletions", e))?; + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Stage specific files. +/// Replaces: `git add -- ...` +#[napi] +pub fn git_add_paths(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + index + .add_all(paths.iter(), IndexAddOption::DEFAULT, None) + .map_err(|e| git_err("Failed to add paths", e))?; + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Unstage files (reset index entries to HEAD for specific paths). +/// Replaces: `git reset HEAD -- ` +#[napi] +pub fn git_reset_paths(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Get HEAD commit's tree + let head_obj = match repo.head() { + Ok(head) => Some( + head.peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?, + ), + Err(_) => None, + }; + + let pathspecs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + + repo.reset_default(head_obj.as_ref(), pathspecs.iter()) + .map_err(|e| git_err("Failed to reset paths", e))?; + + Ok(()) +} + +/// Create a commit from the current index. +/// Returns the commit SHA. +/// Replaces: `git commit -m `, `git commit --no-verify -F -` +#[napi] +pub fn git_commit( + repo_path: String, + message: String, + allow_empty: Option, +) -> Result { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + // If message is empty, read from MERGE_MSG or SQUASH_MSG (--no-edit equivalent) + let message = if message.is_empty() { + let merge_msg_path = repo.path().join("MERGE_MSG"); + let squash_msg_path = repo.path().join("SQUASH_MSG"); + if merge_msg_path.exists() { + std::fs::read_to_string(&merge_msg_path) + .unwrap_or_else(|_| "Merge commit".to_string()) + } else if squash_msg_path.exists() { + std::fs::read_to_string(&squash_msg_path) + .unwrap_or_else(|_| "Squash commit".to_string()) + } else { + "Merge commit".to_string() + } + } else { + message + }; + + // Write the index as a tree + let tree_oid = index + .write_tree() + .map_err(|e| git_err("Failed to write tree", e))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| git_err("Failed to find tree", e))?; + + // Get parent commit(s) + let parent = match repo.head() { + Ok(head) => Some( + head.peel_to_commit() + .map_err(|e| git_err("Failed to peel HEAD to commit", e))?, + ), + Err(_) => None, // Initial commit + }; + + // Check if there are changes (unless allow_empty) + if !allow_empty.unwrap_or(false) { + if let Some(ref p) = parent { + let parent_tree = p + .tree() + .map_err(|e| git_err("Failed to get parent tree", e))?; + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) + .map_err(|e| git_err("Failed to diff for empty check", e))?; + if diff.deltas().len() == 0 { + return Err(Error::new( + Status::GenericFailure, + "nothing to commit, working tree clean", + )); + } + } + } + + // Create the signature from git config + let sig = repo + .signature() + .map_err(|e| git_err("Failed to get signature", e))?; + + let parents: Vec<&git2::Commit> = parent.iter().collect(); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents) + .map_err(|e| git_err("Failed to create commit", e))?; + + // Clean up merge/squash message files after commit + for msg_file in &["SQUASH_MSG", "MERGE_MSG"] { + let msg_path = repo.path().join(msg_file); + if msg_path.exists() { + std::fs::remove_file(&msg_path) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to clean up {msg_file}: {e}")))?; + } + } + + Ok(format!("{oid}")) +} + +/// Checkout a branch (switch HEAD and update working tree). +/// Replaces: `git checkout ` +#[napi] +pub fn git_checkout_branch(repo_path: String, branch: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let refname = format!("refs/heads/{branch}"); + let obj = repo + .revparse_single(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + repo.checkout_tree( + &obj, + Some(CheckoutBuilder::new().safe().recreate_missing(true)), + ) + .map_err(|e| git_err(&format!("Failed to checkout '{branch}'"), e))?; + + repo.set_head(&refname) + .map_err(|e| git_err(&format!("Failed to set HEAD to '{branch}'"), e))?; + + Ok(()) +} + +/// Resolve index conflicts by accepting "theirs" version for specific paths. +/// Replaces: `git checkout --theirs -- ` +#[napi] +pub fn git_checkout_theirs(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + for path in &paths { + // Find the "theirs" (stage 3) entry in the index + if let Some(entry) = index.get_path(Path::new(path), 3) { + // Copy the entry data we need before mutating the index + let blob_id = entry.id; + let entry_mode = entry.mode; + let entry_path = entry.path.clone(); + + // Remove all conflict stages + index.remove_path(Path::new(path)).ok(); + + // Create a new stage-0 entry with the "theirs" content + let resolved = git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: entry_mode, + uid: 0, + gid: 0, + file_size: 0, + id: blob_id, + flags: 0, // stage 0 + flags_extended: 0, + path: entry_path, + }; + index + .add(&resolved) + .map_err(|e| git_err(&format!("Failed to add resolved '{path}'"), e))?; + + // Also checkout the file to working directory (with path traversal validation) + let blob = repo + .find_blob(blob_id) + .map_err(|e| git_err(&format!("Failed to find blob for '{path}'"), e))?; + let full_path = validate_path_within_repo(&repo_path, path)?; + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to create directory for '{path}': {e}")))?; + } + std::fs::write(&full_path, blob.content()) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to write '{path}': {e}")))?; + } + } + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Squash-merge a branch into the current branch. +/// Stages changes in the index but does NOT create a commit. +/// Replaces: `git merge --squash ` +#[napi] +pub fn git_merge_squash(repo_path: String, branch: String) -> Result { + let repo = open_repo(&repo_path)?; + + let refname = format!("refs/heads/{branch}"); + let their_commit = repo + .find_reference(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{branch}' to commit"), e))?; + + let annotated = repo + .find_annotated_commit(their_commit.id()) + .map_err(|e| git_err("Failed to create annotated commit", e))?; + + // Perform the merge analysis + let (analysis, _) = repo + .merge_analysis(&[&annotated]) + .map_err(|e| git_err("Failed to analyze merge", e))?; + + if analysis.is_up_to_date() { + return Ok(GitMergeResult { + success: true, + conflicts: vec![], + }); + } + + // Perform the merge into the index + let mut merge_opts = MergeOptions::new(); + let mut checkout_opts = CheckoutBuilder::new(); + checkout_opts.safe().allow_conflicts(true); + + repo.merge(&[&annotated], Some(&mut merge_opts), Some(&mut checkout_opts)) + .map_err(|e| git_err("Failed to merge", e))?; + + // Check for conflicts + let index = repo + .index() + .map_err(|e| git_err("Failed to read index after merge", e))?; + + let mut conflicts = Vec::new(); + if index.has_conflicts() { + if let Ok(conflict_iter) = index.conflicts() { + for conflict in conflict_iter.flatten() { + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .map(|entry| String::from_utf8_lossy(&entry.path).to_string()); + + if let Some(p) = path { + conflicts.push(p); + } + } + } + } + + // For squash merge: clean up merge state (we don't want MERGE_HEAD) + // This mimics `git merge --squash` which doesn't record the merge + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup merge state", e))?; + + Ok(GitMergeResult { + success: conflicts.is_empty(), + conflicts, + }) +} + +/// Abort an in-progress merge. +/// Replaces: `git merge --abort` +#[napi] +pub fn git_merge_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Reset to HEAD + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; + let obj = head + .peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?; + + repo.reset(&obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset", e))?; + + // Clean up merge state files + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup merge state", e))?; + + Ok(()) +} + +/// Abort an in-progress rebase. +/// Replaces: `git rebase --abort` +#[napi] +pub fn git_rebase_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Check for rebase state and abort + let git_dir = repo.path(); + let rebase_merge = git_dir.join("rebase-merge"); + let rebase_apply = git_dir.join("rebase-apply"); + + if rebase_merge.exists() || rebase_apply.exists() { + // Read ORIG_HEAD to know where to reset + let orig_head_path = git_dir.join("ORIG_HEAD"); + if let Ok(orig_ref) = std::fs::read_to_string(&orig_head_path) { + let oid_str = orig_ref.trim(); + if let Ok(oid) = git2::Oid::from_str(oid_str) { + if let Ok(commit) = repo.find_commit(oid) { + let obj = commit.as_object(); + repo.reset(obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset to ORIG_HEAD", e))?; + } + } + } + + // Clean up rebase state directories + if rebase_merge.exists() { + std::fs::remove_dir_all(&rebase_merge) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-merge state: {e}")))?; + } + if rebase_apply.exists() { + std::fs::remove_dir_all(&rebase_apply) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-apply state: {e}")))?; + } + } + + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup repo state", e))?; + Ok(()) +} + +/// Hard reset to HEAD. +/// Replaces: `git reset --hard HEAD` +#[napi] +pub fn git_reset_hard(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; + let obj = head + .peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?; + + repo.reset(&obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset", e))?; + + Ok(()) +} + +/// Delete a branch. +/// Replaces: `git branch -D ` (force=true) or `git branch -d ` (force=false) +#[napi] +pub fn git_branch_delete(repo_path: String, branch: String, force: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let mut git_branch = repo + .find_branch(&branch, BranchType::Local) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + if force.unwrap_or(false) { + // Force delete (like -D): delete the ref directly + let refname = format!("refs/heads/{branch}"); + if let Ok(mut reference) = repo.find_reference(&refname) { + reference + .delete() + .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?; + } + } else { + // Safe delete (like -d): only if fully merged + git_branch + .delete() + .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?; + } + + Ok(()) +} + +/// Force-reset a branch to point at a target ref. +/// Replaces: `git branch -f ` +#[napi] +pub fn git_branch_force_reset( + repo_path: String, + branch: String, + target: String, +) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let target_commit = repo + .revparse_single(&target) + .map_err(|e| git_err(&format!("Failed to resolve '{target}'"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{target}' to commit"), e))?; + + repo.branch(&branch, &target_commit, true) + .map_err(|e| git_err(&format!("Failed to reset branch '{branch}'"), e))?; + + Ok(()) +} + +/// Remove files from the index (cache) without touching the working tree. +/// Returns the list of files that were actually removed. +/// Replaces: `git rm --cached -r --ignore-unmatch ` +#[napi] +pub fn git_rm_cached( + repo_path: String, + paths: Vec, + recursive: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + let is_recursive = recursive.unwrap_or(true); + let mut removed = Vec::new(); + + for path in &paths { + if is_recursive && (path.ends_with('/') || Path::new(&repo_path).join(path).is_dir()) { + // Remove all entries under this directory + let prefix = if path.ends_with('/') { + path.clone() + } else { + format!("{path}/") + }; + let entries_to_remove: Vec = index + .iter() + .filter_map(|entry| { + let entry_path = String::from_utf8_lossy(&entry.path).to_string(); + if entry_path.starts_with(&prefix) || entry_path == path.trim_end_matches('/') { + Some(entry_path) + } else { + None + } + }) + .collect(); + + for entry_path in &entries_to_remove { + if index.remove_path(Path::new(entry_path)).is_ok() { + removed.push(format!("rm '{entry_path}'")); + } + } + } else { + if index.remove_path(Path::new(path)).is_ok() { + removed.push(format!("rm '{path}'")); + } + } + } + + if !removed.is_empty() { + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + } + + Ok(removed) +} + +/// Force-remove files from both index and working tree. +/// Replaces: `git rm --force -- ` +#[napi] +pub fn git_rm_force(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + for path in &paths { + index.remove_path(Path::new(path)) + .map_err(|e| git_err(&format!("Failed to remove '{path}' from index"), e))?; + // Also delete from working tree (with path traversal validation) + let full_path = validate_path_within_repo(&repo_path, path)?; + if full_path.exists() { + std::fs::remove_file(&full_path) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to delete '{path}': {e}")))?; + } + } + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Add a new git worktree. +/// Replaces: `git worktree add [-b ] ` +#[napi] +pub fn git_worktree_add( + repo_path: String, + wt_path: String, + branch: String, + create_branch: Option, + start_point: Option, +) -> Result<()> { + let repo = open_repo(&repo_path)?; + + if create_branch.unwrap_or(false) { + // Create a new branch from start_point, then add worktree + let start = start_point.as_deref().unwrap_or("HEAD"); + let start_commit = repo + .revparse_single(start) + .map_err(|e| git_err(&format!("Failed to resolve '{start}'"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{start}' to commit"), e))?; + + repo.branch(&branch, &start_commit, false) + .map_err(|e| git_err(&format!("Failed to create branch '{branch}'"), e))?; + } + + // Use git worktree add via the worktree API + let refname = format!("refs/heads/{branch}"); + let reference = repo + .find_reference(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + repo.worktree( + &branch, // worktree name + Path::new(&wt_path), + Some( + git2::WorktreeAddOptions::new() + .reference(Some(&reference)), + ), + ) + .map_err(|e| git_err(&format!("Failed to add worktree at '{wt_path}'"), e))?; + + Ok(()) +} + +/// Remove a git worktree. +/// Replaces: `git worktree remove [--force] ` +#[napi] +pub fn git_worktree_remove(repo_path: String, wt_path: String, force: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Find the worktree by path + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + let path_str = wt.path().to_string_lossy().to_string(); + let normalized_wt = path_str.trim_end_matches('/'); + let normalized_target = wt_path.trim_end_matches('/'); + if normalized_wt == normalized_target { + if force.unwrap_or(false) { + // Force: validate (which marks it as prunable) then remove dir + wt.validate().ok(); // May fail if already invalid — that's fine + if wt.path().exists() { + std::fs::remove_dir_all(wt.path()).ok(); + } + // Prune the entry + wt.prune(Some( + git2::WorktreePruneOptions::new() + .valid(true) + .locked(true) + .working_tree(true), + )) + .ok(); + } else if wt.validate().is_ok() { + // Only prune if the worktree is valid + if wt.path().exists() { + std::fs::remove_dir_all(wt.path()).ok(); + } + wt.prune(Some(git2::WorktreePruneOptions::new().valid(true))) + .ok(); + } + return Ok(()); + } + } + } + } + + // If worktree not found in git's list, try to clean up the directory anyway + let wt = Path::new(&wt_path); + if wt.exists() && force.unwrap_or(false) { + std::fs::remove_dir_all(wt).ok(); + } + + Ok(()) +} + +/// Prune stale worktree entries. +/// Replaces: `git worktree prune` +#[napi] +pub fn git_worktree_prune(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + if wt.validate().is_err() { + // Worktree is invalid (directory missing, etc.) — prune it + wt.prune(Some( + git2::WorktreePruneOptions::new() + .valid(false) + .working_tree(true), + )) + .ok(); + } + } + } + } + + Ok(()) +} + +/// Revert a commit without auto-committing. +/// Replaces: `git revert --no-commit ` +#[napi] +pub fn git_revert_commit(repo_path: String, sha: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let oid = git2::Oid::from_str(&sha) + .map_err(|e| git_err(&format!("Invalid SHA '{sha}'"), e))?; + + let commit = repo + .find_commit(oid) + .map_err(|e| git_err(&format!("Commit '{sha}' not found"), e))?; + + repo.revert(&commit, None) + .map_err(|e| git_err(&format!("Failed to revert commit '{sha}'"), e))?; + + // Clean up revert state since we don't want to auto-commit + // (git revert --no-commit semantics) + repo.cleanup_state().ok(); + + Ok(()) +} + +/// Abort an in-progress revert. +/// Replaces: `git revert --abort` +#[napi] +pub fn git_revert_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Reset to HEAD + if let Ok(head) = repo.head() { + if let Ok(obj) = head.peel(ObjectType::Commit) { + repo.reset(&obj, ResetType::Hard, None).ok(); + } + } + + repo.cleanup_state().ok(); + Ok(()) +} + +/// Create or delete a ref. +/// When `target` is provided, creates/updates the ref to point at target. +/// When `target` is None, deletes the ref. +/// Replaces: `git update-ref HEAD` and `git update-ref -d ` +#[napi] +pub fn git_update_ref(repo_path: String, refname: String, target: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + match target { + Some(target_ref) => { + let oid = resolve_ref(&repo, &target_ref)?; + repo.reference(&refname, oid, true, "update-ref") + .map_err(|e| git_err(&format!("Failed to update ref '{refname}'"), e))?; + } + None => { + if let Ok(mut reference) = repo.find_reference(&refname) { + reference + .delete() + .map_err(|e| git_err(&format!("Failed to delete ref '{refname}'"), e))?; + } + } + } + + Ok(()) } diff --git a/native/crates/engine/src/gsd_parser.rs b/native/crates/engine/src/gsd_parser.rs index 325377392..b4a7dc279 100644 --- a/native/crates/engine/src/gsd_parser.rs +++ b/native/crates/engine/src/gsd_parser.rs @@ -47,6 +47,9 @@ pub struct ParsedGsdFile { pub body: String, /// Map of section heading -> content, serialized as JSON. pub sections: String, + /// Original raw file content. + #[napi(js_name = "rawContent")] + pub raw_content: String, } /// Batch parse result. @@ -769,6 +772,7 @@ pub fn batch_parse_gsd_files(directory: String) -> Result { metadata, body: body.to_string(), sections: sections_json, + raw_content: content.clone(), }); } @@ -831,6 +835,546 @@ pub fn parse_roadmap_file(content: String) -> NativeRoadmap { parse_roadmap_internal(&content) } +// ─── GSD Tree Scanner ─────────────────────────────────────────────────────── + +#[napi(object)] +pub struct GsdTreeEntry { + pub path: String, + pub name: String, + #[napi(js_name = "isDir")] + pub is_dir: bool, +} + +#[napi(js_name = "scanGsdTree")] +pub fn scan_gsd_tree(directory: String) -> Result> { + let base = Path::new(&directory); + if !base.exists() { + return Ok(Vec::new()); + } + let mut entries = Vec::new(); + collect_tree_entries(base, base, &mut entries)?; + Ok(entries) +} + +fn collect_tree_entries(base: &Path, dir: &Path, entries: &mut Vec) -> Result<()> { + let read_dir = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(e) => { + return Err(napi::Error::from_reason(format!( + "Failed to read directory {}: {}", + dir.display(), + e + ))); + } + }; + + for entry in read_dir { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + let relative = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = file_type.is_dir(); + + entries.push(GsdTreeEntry { + path: relative, + name, + is_dir, + }); + + if is_dir { + collect_tree_entries(base, &path, entries)?; + } + } + Ok(()) +} + +// ─── JSONL Tail Parser ────────────────────────────────────────────────────── + +#[napi(object)] +pub struct JsonlParseResult { + pub entries: String, + pub count: u32, + #[napi(js_name = "truncated")] + pub truncated: bool, +} + +#[napi(js_name = "parseJsonlTail")] +pub fn parse_jsonl_tail( + file_path: String, + max_bytes: Option, + max_entries: Option, +) -> Result { + use std::io::{Read, Seek, SeekFrom}; + + let max_bytes = max_bytes.unwrap_or(10 * 1024 * 1024) as u64; // default 10MB + let max_entries = max_entries.map(|m| m as usize); + + let mut file = match std::fs::File::open(&file_path) { + Ok(f) => f, + Err(e) => { + return Err(napi::Error::from_reason(format!( + "Failed to open file {}: {}", + file_path, e + ))); + } + }; + + let file_len = file + .metadata() + .map_err(|e| napi::Error::from_reason(format!("Failed to get file metadata: {}", e)))? + .len(); + + let truncated = file_len > max_bytes; + + let content = if truncated { + let offset = file_len - max_bytes; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| napi::Error::from_reason(format!("Failed to seek: {}", e)))?; + let mut buf = String::new(); + file.read_to_string(&mut buf) + .map_err(|e| napi::Error::from_reason(format!("Failed to read file: {}", e)))?; + buf + } else { + let mut buf = String::new(); + file.read_to_string(&mut buf) + .map_err(|e| napi::Error::from_reason(format!("Failed to read file: {}", e)))?; + buf + }; + + let lines: Vec<&str> = content.split('\n').collect(); + + let mut valid_entries: Vec<&str> = Vec::new(); + for line in &lines { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // Validate JSON + if serde_json::from_str::(trimmed).is_ok() { + valid_entries.push(trimmed); + } + } + + // If max_entries is set, take only the last N entries + if let Some(max) = max_entries { + if valid_entries.len() > max { + let skip = valid_entries.len() - max; + valid_entries = valid_entries[skip..].to_vec(); + } + } + + let count = valid_entries.len() as u32; + let mut entries_json = String::from("["); + for (i, entry) in valid_entries.iter().enumerate() { + if i > 0 { + entries_json.push(','); + } + entries_json.push_str(entry); + } + entries_json.push(']'); + + Ok(JsonlParseResult { + entries: entries_json, + count, + truncated, + }) +} + +// ─── Plan File Parser ─────────────────────────────────────────────────────── + +#[napi(object)] +pub struct NativeTaskEntry { + pub id: String, + pub title: String, + pub description: String, + pub done: bool, + pub estimate: String, + pub files: Vec, + pub verify: String, +} + +#[napi(object)] +pub struct NativePlan { + pub id: String, + pub title: String, + pub goal: String, + pub demo: String, + #[napi(js_name = "mustHaves")] + pub must_haves: Vec, + pub tasks: Vec, + #[napi(js_name = "filesLikelyTouched")] + pub files_likely_touched: Vec, +} + +#[napi(js_name = "parsePlanFile")] +pub fn parse_plan_file(content: String) -> NativePlan { + let (fm_lines, body) = split_frontmatter_internal(&content); + + // Extract id from frontmatter if present, otherwise from heading + let fm_map = fm_lines + .map(|lines| parse_frontmatter_map_internal(&lines)) + .unwrap_or_default(); + + let fm_id = fm_map.iter().find_map(|(k, v)| { + if k == "id" { + if let FmValue::Scalar(s) = v { + Some(s.clone()) + } else { + None + } + } else { + None + } + }); + + // Extract title from # heading: "# ID: Title" + let (heading_id, title) = body + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| { + let heading = l[2..].trim(); + if let Some(colon_pos) = heading.find(": ") { + ( + heading[..colon_pos].trim().to_string(), + heading[colon_pos + 2..].trim().to_string(), + ) + } else { + (String::new(), heading.to_string()) + } + }) + .unwrap_or_default(); + + let id = fm_id.unwrap_or(heading_id); + + let goal = extract_bold_field(body, "Goal") + .unwrap_or("") + .to_string(); + + let demo = extract_bold_field(body, "Demo") + .unwrap_or("") + .to_string(); + + let must_haves = extract_section_internal(body, "Must-Haves", 2) + .map(|s| parse_bullets(&s)) + .unwrap_or_default(); + + let tasks = parse_plan_tasks(body); + + let files_likely_touched = extract_section_internal(body, "Files Likely Touched", 2) + .map(|s| parse_bullets(&s)) + .unwrap_or_default(); + + NativePlan { + id, + title, + goal, + demo, + must_haves, + tasks, + files_likely_touched, + } +} + +fn parse_plan_tasks(body: &str) -> Vec { + let tasks_section = match extract_section_internal(body, "Tasks", 2) { + Some(s) => s, + None => return Vec::new(), + }; + + let mut tasks: Vec = Vec::new(); + + for line in tasks_section.lines() { + let trimmed = line.trim(); + + // Check for task checkbox line: - [x] **T01: Task Title** `est:2h` + if trimmed.starts_with("- [") && trimmed.len() > 4 { + let done_char = trimmed.chars().nth(3).unwrap_or(' '); + let done = done_char == 'x' || done_char == 'X'; + + let after_bracket = match trimmed.find("] ") { + Some(pos) => &trimmed[pos + 2..], + None => continue, + }; + + if !after_bracket.starts_with("**") { + continue; + } + + let bold_end = match after_bracket[2..].find("**") { + Some(pos) => pos, + None => continue, + }; + let bold_content = &after_bracket[2..2 + bold_end]; + + let (id, title) = if let Some(colon_pos) = bold_content.find(": ") { + ( + bold_content[..colon_pos].trim().to_string(), + bold_content[colon_pos + 2..].trim().to_string(), + ) + } else { + (String::new(), bold_content.to_string()) + }; + + let after_bold = &after_bracket[2 + bold_end + 2..]; + let estimate = if let Some(est_start) = after_bold.find("`est:") { + let val_start = est_start + 5; + let val_end = after_bold[val_start..] + .find('`') + .unwrap_or(0) + + val_start; + after_bold[val_start..val_end].to_string() + } else { + String::new() + }; + + tasks.push(NativeTaskEntry { + id, + title, + description: String::new(), + done, + estimate, + files: Vec::new(), + verify: String::new(), + }); + continue; + } + + // Sub-items under a task + if let Some(task) = tasks.last_mut() { + if trimmed.starts_with("- Files:") || trimmed.starts_with("- files:") { + let files_str = trimmed[8..].trim(); + task.files = files_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } else if trimmed.starts_with("- Verify:") || trimmed.starts_with("- verify:") { + task.verify = trimmed[9..].trim().to_string(); + } else if trimmed.starts_with("- ") && !trimmed.starts_with("- [") { + // Description line + if task.description.is_empty() { + task.description = trimmed[2..].trim().to_string(); + } + } + } + } + + tasks +} + +// ─── Summary File Parser ──────────────────────────────────────────────────── + +#[napi(object)] +pub struct NativeFileModified { + pub path: String, + pub description: String, +} + +#[napi(object)] +pub struct NativeSummaryFrontmatter { + pub id: String, + pub parent: String, + pub milestone: String, + pub provides: Vec, + pub affects: Vec, + #[napi(js_name = "keyFiles")] + pub key_files: Vec, + #[napi(js_name = "keyDecisions")] + pub key_decisions: Vec, + #[napi(js_name = "patternsEstablished")] + pub patterns_established: Vec, + #[napi(js_name = "drillDownPaths")] + pub drill_down_paths: Vec, + #[napi(js_name = "observabilitySurfaces")] + pub observability_surfaces: Vec, + pub duration: String, + #[napi(js_name = "verificationResult")] + pub verification_result: String, + #[napi(js_name = "completedAt")] + pub completed_at: String, + #[napi(js_name = "blockerDiscovered")] + pub blocker_discovered: bool, +} + +#[napi(object)] +pub struct NativeSummary { + pub frontmatter: NativeSummaryFrontmatter, + pub title: String, + #[napi(js_name = "oneLiner")] + pub one_liner: String, + #[napi(js_name = "whatHappened")] + pub what_happened: String, + pub deviations: String, + #[napi(js_name = "filesModified")] + pub files_modified: Vec, +} + +#[napi(js_name = "parseSummaryFile")] +pub fn parse_summary_file(content: String) -> NativeSummary { + let (fm_lines, body) = split_frontmatter_internal(&content); + + let fm_map = fm_lines + .map(|lines| parse_frontmatter_map_internal(&lines)) + .unwrap_or_default(); + + let frontmatter = parse_summary_frontmatter(&fm_map); + + let title = body + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l[2..].trim().to_string()) + .unwrap_or_default(); + + // One-liner: first bold line after h1 + let one_liner = { + let mut found_h1 = false; + let mut result = String::new(); + for line in body.lines() { + if line.starts_with("# ") { + found_h1 = true; + continue; + } + if found_h1 { + let trimmed = line.trim(); + if trimmed.starts_with("**") && trimmed.ends_with("**") { + result = trimmed[2..trimmed.len() - 2].to_string(); + break; + } + if !trimmed.is_empty() && !trimmed.starts_with('#') { + break; + } + } + } + result + }; + + let what_happened = extract_section_internal(body, "What Happened", 2) + .unwrap_or_default(); + + let deviations = extract_section_internal(body, "Deviations", 2) + .unwrap_or_default(); + + let files_modified = extract_section_internal(body, "Files Created/Modified", 2) + .or_else(|| extract_section_internal(body, "Files Modified", 2)) + .map(|s| parse_files_modified(&s)) + .unwrap_or_default(); + + NativeSummary { + frontmatter, + title, + one_liner, + what_happened, + deviations, + files_modified, + } +} + +fn parse_summary_frontmatter(fm_map: &[(String, FmValue)]) -> NativeSummaryFrontmatter { + let get_scalar = |key: &str| -> String { + fm_map + .iter() + .find_map(|(k, v)| { + if k == key { + if let FmValue::Scalar(s) = v { + Some(s.clone()) + } else { + None + } + } else { + None + } + }) + .unwrap_or_default() + }; + + let get_string_array = |key: &str| -> Vec { + fm_map + .iter() + .find_map(|(k, v)| { + if k == key { + if let FmValue::Array(items) = v { + Some( + items + .iter() + .filter_map(|item| { + if let FmArrayItem::Str(s) = item { + Some(s.clone()) + } else { + None + } + }) + .collect(), + ) + } else { + None + } + } else { + None + } + }) + .unwrap_or_default() + }; + + let blocker_str = get_scalar("blocker_discovered"); + let blocker_discovered = + blocker_str == "true" || blocker_str == "yes" || blocker_str == "True"; + + NativeSummaryFrontmatter { + id: get_scalar("id"), + parent: get_scalar("parent"), + milestone: get_scalar("milestone"), + provides: get_string_array("provides"), + affects: get_string_array("affects"), + key_files: get_string_array("key_files"), + key_decisions: get_string_array("key_decisions"), + patterns_established: get_string_array("patterns_established"), + drill_down_paths: get_string_array("drill_down_paths"), + observability_surfaces: get_string_array("observability_surfaces"), + duration: get_scalar("duration"), + verification_result: get_scalar("verification_result"), + completed_at: get_scalar("completed_at"), + blocker_discovered, + } +} + +fn parse_files_modified(section: &str) -> Vec { + let mut files = Vec::new(); + for line in section.lines() { + let trimmed = line.trim(); + let text = if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + &trimmed[2..] + } else { + continue; + }; + + // Parse `path` — description or `path` - description + if text.starts_with('`') { + if let Some(end_tick) = text[1..].find('`') { + let path = text[1..1 + end_tick].to_string(); + let rest = text[1 + end_tick + 1..].trim(); + let description = if rest.starts_with("—") || rest.starts_with("–") || rest.starts_with('-') { + rest[rest.find(|c: char| c != '—' && c != '–' && c != '-').unwrap_or(rest.len())..].trim().to_string() + } else { + rest.to_string() + }; + files.push(NativeFileModified { path, description }); + } + } + } + files +} + // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index 5fbead5da..76c47fec5 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-arm64", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD native engine binary for macOS ARM64", "os": [ "darwin" diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index 1bba41646..cdbd7d01d 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-darwin-x64", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD native engine binary for macOS Intel", "os": [ "darwin" diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 0c8f04b9c..790511e1d 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-arm64-gnu", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD native engine binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 5e6c4a8be..cdbafbe2d 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-linux-x64-gnu", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD native engine binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index b0074d7b0..7de036f6c 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@gsd-build/engine-win32-x64-msvc", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD native engine binary for Windows x64 (MSVC)", "os": [ "win32" diff --git a/package-lock.json b/package-lock.json index 94a0f0abd..f755a56fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.14.4", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.14.4", + "version": "2.16.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 2dd2c9a89..a0cb86a4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.14.4", + "version": "2.17.0", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { @@ -61,7 +61,8 @@ "sync-pkg-version": "node scripts/sync-pkg-version.cjs", "sync-platform-versions": "node native/scripts/sync-platform-versions.cjs", "validate-pack": "bash scripts/validate-pack.sh", - "prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1) && npm run build && npm run validate-pack" + "typecheck:extensions": "tsc --noEmit --project tsconfig.extensions.json", + "prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1) && npm run build && npm run typecheck:extensions && npm run validate-pack" }, "dependencies": { "@anthropic-ai/sdk": "^0.73.0", diff --git a/packages/pi-ai/src/models.generated.ts b/packages/pi-ai/src/models.generated.ts index 1a4d862a8..85eb1fa85 100644 --- a/packages/pi-ai/src/models.generated.ts +++ b/packages/pi-ai/src/models.generated.ts @@ -13523,9 +13523,63 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, "ollama-cloud": { - "llama3.1:8b": { - id: "llama3.1:8b", - name: "Llama 3.1 8B", + "cogito-2.1:671b": { + id: "cogito-2.1:671b", + name: "Cogito 2.1 671B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "deepseek-v3.1:671b": { + id: "deepseek-v3.1:671b", + name: "DeepSeek V3.1 671B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "deepseek-v3.2": { + id: "deepseek-v3.2", + name: "DeepSeek V3.2", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "devstral-2:123b": { + id: "devstral-2:123b", + name: "Devstral 2 123B", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", @@ -13538,48 +13592,30 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, - "llama3.1:70b": { - id: "llama3.1:70b", - name: "Llama 3.1 70B", + "devstral-small-2:24b": { + id: "devstral-small-2:24b", + name: "Devstral Small 2 24B", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, - "llama3.1:405b": { - id: "llama3.1:405b", - name: "Llama 3.1 405B", - api: "openai-completions", - provider: "ollama-cloud", - baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen3:8b": { - id: "qwen3:8b", - name: "Qwen 3 8B", + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", @@ -13592,62 +13628,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen3:32b": { - id: "qwen3:32b", - name: "Qwen 3 32B", - api: "openai-completions", - provider: "ollama-cloud", - baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "deepseek-r1:8b": { - id: "deepseek-r1:8b", - name: "DeepSeek R1 8B", - api: "openai-completions", - provider: "ollama-cloud", - baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "deepseek-r1:70b": { - id: "deepseek-r1:70b", - name: "DeepSeek R1 70B", - api: "openai-completions", - provider: "ollama-cloud", - baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"openai-completions">, "gemma3:12b": { id: "gemma3:12b", @@ -13657,7 +13639,7 @@ export const MODELS = { baseUrl: "https://ollama.com/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, @@ -13665,7 +13647,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 8192, + maxTokens: 131072, } satisfies Model<"openai-completions">, "gemma3:27b": { id: "gemma3:27b", @@ -13675,7 +13657,7 @@ export const MODELS = { baseUrl: "https://ollama.com/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, @@ -13683,17 +13665,17 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 8192, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "mistral:7b": { - id: "mistral:7b", - name: "Mistral 7B", + "gemma3:4b": { + id: "gemma3:4b", + name: "Gemma 3 4B", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, @@ -13701,16 +13683,16 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 8192, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "phi4:14b": { - id: "phi4:14b", - name: "Phi-4 14B", + "glm-4.6": { + id: "glm-4.6", + name: "GLM 4.6", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: false, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, input: ["text"], cost: { input: 0, @@ -13718,17 +13700,17 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 202752, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "gpt-oss:20b": { - id: "gpt-oss:20b", - name: "GPT-OSS 20B", + "glm-4.7": { + id: "glm-4.7", + name: "GLM 4.7", api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: false, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, input: ["text"], cost: { input: 0, @@ -13736,8 +13718,26 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5": { + id: "glm-5", + name: "GLM 5", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, } satisfies Model<"openai-completions">, "gpt-oss:120b": { id: "gpt-oss:120b", @@ -13745,6 +13745,42 @@ export const MODELS = { api: "openai-completions", provider: "ollama-cloud", baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "gpt-oss:20b": { + id: "gpt-oss:20b", + name: "GPT-OSS 20B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "kimi-k2:1t": { + id: "kimi-k2:1t", + name: "Kimi K2 1T", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, reasoning: false, input: ["text"], @@ -13754,8 +13790,332 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking": { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "minimax-m2.1": { + id: "minimax-m2.1", + name: "Minimax M2.1", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m2.5": { + id: "minimax-m2.5", + name: "Minimax M2.5", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m2": { + id: "minimax-m2", + name: "Minimax M2", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "ministral-3:14b": { + id: "ministral-3:14b", + name: "Ministral 3 14B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "ministral-3:3b": { + id: "ministral-3:3b", + name: "Ministral 3 3B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "ministral-3:8b": { + id: "ministral-3:8b", + name: "Ministral 3 8B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-large-3:675b": { + id: "mistral-large-3:675b", + name: "Mistral Large 3 675B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nemotron-3-nano:30b": { + id: "nemotron-3-nano:30b", + name: "Nemotron 3 Nano 30B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nemotron-3-super": { + id: "nemotron-3-super", + name: "Nemotron 3 Super", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen3.5:397b": { + id: "qwen3.5:397b", + name: "Qwen 3.5 397B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 81920, + } satisfies Model<"openai-completions">, + "qwen3-coder:480b": { + id: "qwen3-coder:480b", + name: "Qwen 3 Coder 480B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen3-coder-next": { + id: "qwen3-coder-next", + name: "Qwen 3 Coder Next", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen3-next:80b": { + id: "qwen3-next:80b", + name: "Qwen 3 Next 80B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen3-vl:235b-instruct": { + id: "qwen3-vl:235b-instruct", + name: "Qwen 3 VL 235B Instruct", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen3-vl:235b": { + id: "qwen3-vl:235b", + name: "Qwen 3 VL 235B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "rnj-1:8b": { + id: "rnj-1:8b", + name: "RNJ 1 8B", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, }, } as const; diff --git a/packages/pi-ai/src/providers/google-shared.test.ts b/packages/pi-ai/src/providers/google-shared.test.ts new file mode 100644 index 000000000..4468ac231 --- /dev/null +++ b/packages/pi-ai/src/providers/google-shared.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeSchemaForGoogle } from "./google-shared.js"; + +// ═══════════════════════════════════════════════════════════════════════════ +// sanitizeSchemaForGoogle +// ═══════════════════════════════════════════════════════════════════════════ + +describe("sanitizeSchemaForGoogle", () => { + it("passes through primitives unchanged", () => { + assert.equal(sanitizeSchemaForGoogle(null), null); + assert.equal(sanitizeSchemaForGoogle(42), 42); + assert.equal(sanitizeSchemaForGoogle("hello"), "hello"); + assert.equal(sanitizeSchemaForGoogle(true), true); + }); + + it("passes through a valid schema with no banned fields", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }; + assert.deepEqual(sanitizeSchemaForGoogle(schema), schema); + }); + + it("removes top-level patternProperties", () => { + const schema = { + type: "object", + patternProperties: { "^S_": { type: "string" } }, + properties: { foo: { type: "string" } }, + }; + const result = sanitizeSchemaForGoogle(schema) as Record; + assert.ok(!("patternProperties" in result)); + assert.deepEqual(result.properties, { foo: { type: "string" } }); + }); + + it("removes nested patternProperties", () => { + const schema = { + type: "object", + properties: { + nested: { + type: "object", + patternProperties: { ".*": { type: "string" } }, + }, + }, + }; + const result = sanitizeSchemaForGoogle(schema) as any; + assert.ok(!("patternProperties" in result.properties.nested)); + }); + + it("converts top-level const to enum", () => { + const schema = { const: "fixed-value" }; + const result = sanitizeSchemaForGoogle(schema) as Record; + assert.deepEqual(result.enum, ["fixed-value"]); + assert.ok(!("const" in result)); + }); + + it("converts const to enum inside anyOf", () => { + const schema = { + anyOf: [{ const: "a" }, { const: "b" }, { type: "string" }], + }; + const result = sanitizeSchemaForGoogle(schema) as any; + assert.deepEqual(result.anyOf[0], { enum: ["a"] }); + assert.deepEqual(result.anyOf[1], { enum: ["b"] }); + assert.deepEqual(result.anyOf[2], { type: "string" }); + }); + + it("converts const to enum inside oneOf", () => { + const schema = { + oneOf: [{ const: "x" }, { const: "y" }], + }; + const result = sanitizeSchemaForGoogle(schema) as any; + assert.deepEqual(result.oneOf[0], { enum: ["x"] }); + assert.deepEqual(result.oneOf[1], { enum: ["y"] }); + }); + + it("recursively sanitizes deeply nested schemas", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + anyOf: [{ const: "deep" }, { type: "null" }], + patternProperties: { ".*": { type: "string" } }, + }, + }, + }, + }, + }; + const result = sanitizeSchemaForGoogle(schema) as any; + const level2 = result.properties.level1.properties.level2; + assert.deepEqual(level2.anyOf[0], { enum: ["deep"] }); + assert.ok(!("patternProperties" in level2)); + }); + + it("sanitizes items in array schemas", () => { + const schema = { + type: "array", + items: { + anyOf: [{ const: "foo" }, { type: "string" }], + }, + }; + const result = sanitizeSchemaForGoogle(schema) as any; + assert.deepEqual(result.items.anyOf[0], { enum: ["foo"] }); + }); + + it("sanitizes arrays of schemas", () => { + const input = [{ const: "a" }, { const: "b" }]; + const result = sanitizeSchemaForGoogle(input) as any[]; + assert.deepEqual(result[0], { enum: ["a"] }); + assert.deepEqual(result[1], { enum: ["b"] }); + }); + + it("preserves non-string const values unchanged", () => { + // Only string const values are converted; number const is passed through + const schema = { const: 42 }; + const result = sanitizeSchemaForGoogle(schema) as Record; + assert.equal(result.const, 42); + assert.ok(!("enum" in result)); + }); + + it("sanitizes additionalProperties", () => { + const schema = { + type: "object", + additionalProperties: { + patternProperties: { "^x-": { type: "string" } }, + }, + }; + const result = sanitizeSchemaForGoogle(schema) as any; + assert.ok(!("patternProperties" in result.additionalProperties)); + }); +}); diff --git a/packages/pi-ai/src/providers/google-shared.ts b/packages/pi-ai/src/providers/google-shared.ts index e942314f9..255928c81 100644 --- a/packages/pi-ai/src/providers/google-shared.ts +++ b/packages/pi-ai/src/providers/google-shared.ts @@ -226,6 +226,52 @@ export function convertMessages(model: Model, contex return contents; } +/** + * Sanitize a JSON Schema for Google's function declarations API. + * Google's API rejects `patternProperties` and `const` fields which are valid in JSON Schema. + * + * This function recursively: + * - Removes all `patternProperties` fields + * - Converts `const: "value"` to `enum: ["value"]` in anyOf/oneOf blocks + * + * This is needed for providers like `google-antigravity` when proxying Claude models, + * since Google Cloud Code Assist uses a restricted subset of JSON Schema. + */ +export function sanitizeSchemaForGoogle(schema: unknown): unknown { + if (!schema || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map((item) => sanitizeSchemaForGoogle(item)); + } + + const obj = schema as Record; + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + // Skip patternProperties entirely — not supported by Google's API + if (key === "patternProperties") { + continue; + } + + // Convert const to enum — Google's API rejects the const keyword + if (key === "const" && typeof value === "string") { + sanitized.enum = [value]; + continue; + } + + // Recursively sanitize all nested objects and arrays + if (typeof value === "object") { + sanitized[key] = sanitizeSchemaForGoogle(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; +} + /** * Convert tools to Gemini function declarations format. * @@ -233,6 +279,9 @@ export function convertMessages(model: Model, contex * anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters` * field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude * models, where the API translates `parameters` into Anthropic's `input_schema`. + * + * The schema is automatically sanitized to remove fields not supported by Google's + * function declarations API (patternProperties, const converted to enum, etc.). */ export function convertTools( tools: Tool[], @@ -244,7 +293,9 @@ export function convertTools( functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, - ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }), + ...(useParameters + ? { parameters: sanitizeSchemaForGoogle(tool.parameters) } + : { parametersJsonSchema: sanitizeSchemaForGoogle(tool.parameters) }), })), }, ]; diff --git a/packages/pi-coding-agent/src/config.ts b/packages/pi-coding-agent/src/config.ts index 2c971aaa3..70297cc16 100644 --- a/packages/pi-coding-agent/src/config.ts +++ b/packages/pi-coding-agent/src/config.ts @@ -77,29 +77,33 @@ export function getUpdateInstruction(packageName: string): string { * - For Node.js (dist/): returns __dirname (the dist/ directory) * - For tsx (src/): returns parent directory (the package root) */ +let _cachedPackageDir: string | undefined; + export function getPackageDir(): string { + if (_cachedPackageDir !== undefined) return _cachedPackageDir; + // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { - if (envDir === "~") return homedir(); - if (envDir.startsWith("~/")) return homedir() + envDir.slice(1); - return envDir; + if (envDir === "~") return (_cachedPackageDir = homedir()); + if (envDir.startsWith("~/")) return (_cachedPackageDir = homedir() + envDir.slice(1)); + return (_cachedPackageDir = envDir); } if (isBunBinary) { // Bun binary: process.execPath points to the compiled executable - return dirname(process.execPath); + return (_cachedPackageDir = dirname(process.execPath)); } // Node.js: walk up from __dirname until we find package.json let dir = __dirname; while (dir !== dirname(dir)) { if (existsSync(join(dir, "package.json"))) { - return dir; + return (_cachedPackageDir = dir); } dir = dirname(dir); } // Fallback (shouldn't happen) - return __dirname; + return (_cachedPackageDir = __dirname); } /** diff --git a/packages/pi-coding-agent/src/core/extensions/types.ts b/packages/pi-coding-agent/src/core/extensions/types.ts index 5e6d08421..1a927cd00 100644 --- a/packages/pi-coding-agent/src/core/extensions/types.ts +++ b/packages/pi-coding-agent/src/core/extensions/types.ts @@ -117,7 +117,7 @@ export interface ExtensionUIContext { input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise; /** Show a notification to the user. */ - notify(message: string, type?: "info" | "warning" | "error"): void; + notify(message: string, type?: "info" | "warning" | "error" | "success"): void; /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; diff --git a/packages/pi-coding-agent/src/core/model-resolver.ts b/packages/pi-coding-agent/src/core/model-resolver.ts index 765ac825d..7318e0588 100644 --- a/packages/pi-coding-agent/src/core/model-resolver.ts +++ b/packages/pi-coding-agent/src/core/model-resolver.ts @@ -13,7 +13,7 @@ import type { ModelRegistry } from "./model-registry.js"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", - anthropic: "claude-opus-4-6", + anthropic: "claude-opus-4-6[1m]", openai: "gpt-5.4", "azure-openai-responses": "gpt-5.2", "openai-codex": "gpt-5.4", @@ -23,7 +23,7 @@ export const defaultModelPerProvider: Record = { "google-vertex": "gemini-3-pro-preview", "github-copilot": "gpt-4o", openrouter: "openai/gpt-5.1-codex", - "vercel-ai-gateway": "anthropic/claude-opus-4-6", + "vercel-ai-gateway": "anthropic/claude-opus-4-6[1m]", xai: "grok-4-fast-non-reasoning", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.6", diff --git a/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts b/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts new file mode 100644 index 000000000..532289f11 --- /dev/null +++ b/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + computeEditDiff, + fuzzyFindText, + generateDiffString, + normalizeForFuzzyMatch, +} from "./edit-diff.js"; + +describe("edit-diff", () => { + it("normalizes quotes, dashes, spaces, and trailing whitespace", () => { + const input = "“hello”\u00A0world — test \nnext\t\t\n"; + assert.equal(normalizeForFuzzyMatch(input), "\"hello\" world - test\nnext\n"); + }); + + it("falls back to fuzzy matching when unicode punctuation differs", () => { + const result = fuzzyFindText("const title = “Hello”;\n", "const title = \"Hello\";\n"); + assert.equal(result.found, true); + assert.equal(result.usedFuzzyMatch, true); + assert.equal(result.contentForReplacement, "const title = \"Hello\";\n"); + }); + + it("renders numbered diffs with the first changed line", () => { + const result = generateDiffString("line 1\nline 2\nline 3\n", "line 1\nline two\nline 3\n"); + assert.equal(result.firstChangedLine, 2); + assert.match(result.diff, /-2 line 2/); + assert.match(result.diff, /\+2 line two/); + }); + + it("respects contextLines and inserts separators for distant changes", () => { + const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`); + const oldContent = lines.join("\n") + "\n"; + const modified = [...lines]; + modified[1] = "changed 2"; // line 2 + modified[17] = "changed 18"; // line 18 + const newContent = modified.join("\n") + "\n"; + + const result = generateDiffString(oldContent, newContent, 2); + // Should contain separator between the two distant change regions + assert.match(result.diff, /\.\.\./); + // Should NOT contain lines far from changes (e.g. line 10) + assert.doesNotMatch(result.diff, /line 10/); + // Should contain the changed lines + assert.match(result.diff, /changed 2/); + assert.match(result.diff, /changed 18/); + }); + + it("handles large files without OOM by falling back to linear diff", () => { + // Create files large enough to exceed the DP threshold + const lineCount = 3000; + const oldLines = Array.from({ length: lineCount }, (_, i) => `line ${i}`); + const newLines = [...oldLines]; + newLines[1500] = "CHANGED"; + const result = generateDiffString(oldLines.join("\n") + "\n", newLines.join("\n") + "\n"); + assert.ok(result.firstChangedLine !== undefined); + assert.match(result.diff, /CHANGED/); + }); + + it("computes diffs for preview without native helpers", async () => { + const dir = mkdtempSync(join(tmpdir(), "edit-diff-test-")); + try { + const file = join(dir, "sample.ts"); + writeFileSync(file, "const title = “Hello”;\n", "utf-8"); + + const result = await computeEditDiff( + file, + "const title = \"Hello\";\n", + "const title = \"Hi\";\n", + dir, + ); + + assert.ok(!("error" in result), "expected a diff result"); + if (!("error" in result)) { + assert.equal(result.firstChangedLine, 1); + assert.match(result.diff, /\+1 const title = "Hi";/); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/pi-coding-agent/src/core/tools/edit-diff.ts b/packages/pi-coding-agent/src/core/tools/edit-diff.ts index b973ca3d9..b0dce1beb 100644 --- a/packages/pi-coding-agent/src/core/tools/edit-diff.ts +++ b/packages/pi-coding-agent/src/core/tools/edit-diff.ts @@ -2,15 +2,11 @@ * Shared diff computation utilities for the edit tool. * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). * - * Hot-path functions (fuzzyFindText, normalizeForFuzzyMatch, generateDiffString) - * delegate to the native Rust engine for performance on large files. + * These helpers intentionally stay in JavaScript. Issue #453 showed that + * post-tool preview paths must not depend on the native addon because a native + * hang there can wedge the entire interactive session after a successful tool run. */ -import { - fuzzyFindText as nativeFuzzyFindText, - generateDiff as nativeGenerateDiff, - normalizeForFuzzyMatch as nativeNormalizeForFuzzyMatch, -} from "@gsd/native"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; import { resolveToCwd } from "./path-utils.js"; @@ -32,14 +28,23 @@ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string } /** - * Normalize text for fuzzy matching (native Rust implementation). + * Normalize text for fuzzy matching. * - Strip trailing whitespace from each line * - Normalize smart quotes to ASCII equivalents * - Normalize Unicode dashes/hyphens to ASCII hyphen * - Normalize special Unicode spaces to regular space */ export function normalizeForFuzzyMatch(text: string): string { - return nativeNormalizeForFuzzyMatch(text); + return text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[‐‑‒–—−]/g, "-") + .replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ") + .split("\n") + .map((line) => line.replace(/[ \t]+$/g, "")) + .join("\n"); } export interface FuzzyMatchResult { @@ -59,14 +64,44 @@ export interface FuzzyMatchResult { } /** - * Find oldText in content, trying exact match first, then fuzzy match - * (native Rust implementation). + * Find oldText in content, trying exact match first, then fuzzy match. * * When fuzzy matching is used, the returned contentForReplacement is the * fuzzy-normalized version of the content. */ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { - return nativeFuzzyFindText(content, oldText); + const exactIndex = content.indexOf(oldText); + if (exactIndex !== -1) { + return { + found: true, + index: exactIndex, + matchLength: oldText.length, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + const normalizedContent = normalizeForFuzzyMatch(content); + const normalizedOldText = normalizeForFuzzyMatch(oldText); + const fuzzyIndex = normalizedContent.indexOf(normalizedOldText); + + if (fuzzyIndex === -1) { + return { + found: false, + index: -1, + matchLength: 0, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + return { + found: true, + index: fuzzyIndex, + matchLength: normalizedOldText.length, + usedFuzzyMatch: true, + contentForReplacement: normalizedContent, + }; } /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ @@ -75,20 +110,81 @@ export function stripBom(content: string): { bom: string; text: string } { } /** - * Generate a unified diff string with line numbers and context - * (native Rust implementation using Myers' algorithm via the `similar` crate). + * Generate a unified diff string with line numbers and context. * * Returns both the diff string and the first changed line number (in the new file). + * Only lines within `contextLines` of a change are included (like unified diff). */ export function generateDiffString( oldContent: string, newContent: string, contextLines = 4, ): { diff: string; firstChangedLine: number | undefined } { - const result = nativeGenerateDiff(oldContent, newContent, contextLines); + const ops = buildLineDiff(oldContent, newContent); + let firstChangedLine: number | undefined; + + // First pass: assign line numbers and find changed indices + const annotated: { op: LineDiffOp; oldLine: number; newLine: number }[] = []; + let oldLine = 1; + let newLine = 1; + const changedIndices: number[] = []; + + for (let idx = 0; idx < ops.length; idx++) { + const op = ops[idx]; + annotated.push({ op, oldLine, newLine }); + + if (op.type !== "context") { + changedIndices.push(idx); + if (firstChangedLine === undefined) { + firstChangedLine = newLine; + } + } + + if (op.type === "remove") { + oldLine += 1; + } else if (op.type === "add") { + newLine += 1; + } else { + oldLine += 1; + newLine += 1; + } + } + + // Build set of indices to include (changes + surrounding context) + const includeSet = new Set(); + for (const ci of changedIndices) { + for (let k = Math.max(0, ci - contextLines); k <= Math.min(ops.length - 1, ci + contextLines); k++) { + includeSet.add(k); + } + } + + const maxLine = Math.max(oldLine - 1, newLine - 1, 1); + const lineNumberWidth = String(maxLine).length; + const rendered: string[] = []; + let lastIncluded = -1; + + for (let idx = 0; idx < annotated.length; idx++) { + if (!includeSet.has(idx)) continue; + + // Insert separator when there's a gap between included regions + if (lastIncluded !== -1 && idx > lastIncluded + 1) { + rendered.push("..."); + } + lastIncluded = idx; + + const { op, oldLine: ol, newLine: nl } = annotated[idx]; + if (op.type === "context") { + rendered.push(` ${String(nl).padStart(lineNumberWidth, " ")} ${op.line}`); + } else if (op.type === "remove") { + rendered.push(`-${String(ol).padStart(lineNumberWidth, " ")} ${op.line}`); + } else { + rendered.push(`+${String(nl).padStart(lineNumberWidth, " ")} ${op.line}`); + } + } + return { - diff: result.diff, - firstChangedLine: result.firstChangedLine ?? undefined, + diff: rendered.join("\n"), + firstChangedLine, }; } @@ -101,6 +197,138 @@ export interface EditDiffError { error: string; } +type LineDiffOp = + | { type: "context"; line: string } + | { type: "remove"; line: string } + | { type: "add"; line: string }; + +function splitLines(text: string): string[] { + const lines = text.split("\n"); + if (lines.length > 0 && lines.at(-1) === "") { + lines.pop(); + } + return lines; +} + +/** + * Maximum number of cells (oldLines * newLines) before we switch from the + * full LCS DP algorithm to a simpler linear-scan diff. This prevents OOM + * on large files (e.g. 10k lines would need a 100M-cell matrix). + */ +const MAX_DP_CELLS = 4_000_000; // ~32 MB for 64-bit numbers + +function buildLineDiff(oldContent: string, newContent: string): LineDiffOp[] { + const oldLines = splitLines(oldContent); + const newLines = splitLines(newContent); + + const cells = (oldLines.length + 1) * (newLines.length + 1); + if (cells > MAX_DP_CELLS) { + return buildLineDiffLinear(oldLines, newLines); + } + + return buildLineDiffLCS(oldLines, newLines); +} + +/** + * Full LCS-based diff using O(n*m) DP table. Produces optimal diffs but + * is only safe for files where n*m <= MAX_DP_CELLS. + */ +function buildLineDiffLCS(oldLines: string[], newLines: string[]): LineDiffOp[] { + const dp: number[][] = Array.from({ length: oldLines.length + 1 }, () => + Array(newLines.length + 1).fill(0), + ); + + for (let i = oldLines.length - 1; i >= 0; i--) { + for (let j = newLines.length - 1; j >= 0; j--) { + if (oldLines[i] === newLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const ops: LineDiffOp[] = []; + let i = 0; + let j = 0; + + while (i < oldLines.length && j < newLines.length) { + if (oldLines[i] === newLines[j]) { + ops.push({ type: "context", line: oldLines[i] }); + i += 1; + j += 1; + continue; + } + + if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ type: "remove", line: oldLines[i] }); + i += 1; + } else { + ops.push({ type: "add", line: newLines[j] }); + j += 1; + } + } + + while (i < oldLines.length) { + ops.push({ type: "remove", line: oldLines[i] }); + i += 1; + } + + while (j < newLines.length) { + ops.push({ type: "add", line: newLines[j] }); + j += 1; + } + + return ops; +} + +/** + * Linear-time fallback diff for large files. Matches common prefix/suffix, + * then treats the remaining middle as a bulk remove+add. Not optimal but + * O(n+m) in both time and space. + */ +function buildLineDiffLinear(oldLines: string[], newLines: string[]): LineDiffOp[] { + const ops: LineDiffOp[] = []; + + // Match common prefix + let prefixLen = 0; + const minLen = Math.min(oldLines.length, newLines.length); + while (prefixLen < minLen && oldLines[prefixLen] === newLines[prefixLen]) { + prefixLen++; + } + + // Match common suffix (not overlapping with prefix) + let suffixLen = 0; + while ( + suffixLen < minLen - prefixLen && + oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen] + ) { + suffixLen++; + } + + // Emit prefix context + for (let i = 0; i < prefixLen; i++) { + ops.push({ type: "context", line: oldLines[i] }); + } + + // Emit removed lines from the middle + for (let i = prefixLen; i < oldLines.length - suffixLen; i++) { + ops.push({ type: "remove", line: oldLines[i] }); + } + + // Emit added lines from the middle + for (let j = prefixLen; j < newLines.length - suffixLen; j++) { + ops.push({ type: "add", line: newLines[j] }); + } + + // Emit suffix context + for (let i = oldLines.length - suffixLen; i < oldLines.length; i++) { + ops.push({ type: "context", line: oldLines[i] }); + } + + return ops; +} + /** * Compute the diff for an edit operation without applying it. * Used for preview rendering in the TUI before the tool executes. diff --git a/packages/pi-coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/pi-coding-agent/src/modes/interactive/components/custom-editor.ts index 74fcc7767..b6968460c 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,4 @@ -import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@gsd/pi-tui"; +import { Editor, type EditorOptions, type EditorTheme, type TUI, isKittyProtocolActive } from "@gsd/pi-tui"; import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; /** @@ -69,6 +69,13 @@ export class CustomEditor extends Editor { // Check all other app actions for (const [action, handler] of this.actionHandlers) { if (action !== "interrupt" && action !== "exit" && this.keybindings.matches(data, action)) { + // When kitty protocol is not active, \x1b\r is ambiguous: + // it could be alt+enter (followUp) or shift+enter mapped via /terminal-setup. + // Prioritize newLine since that's what terminal-setup configures. + // Alt+enter followUp still works in kitty-protocol terminals. + if (action === "followUp" && !isKittyProtocolActive() && data === "\x1b\r") { + break; // Fall through to parent editor's newLine handling + } handler(); return; } diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 3f7a37848..3b64c7bc6 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -1728,7 +1728,7 @@ export class InteractiveMode { /** * Show a notification for extensions. */ - private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void { + private showExtensionNotify(message: string, type?: "info" | "warning" | "error" | "success"): void { if (type === "error") { this.showError(message); } else if (type === "warning") { diff --git a/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts b/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts index e0871e5b0..676d672d9 100644 --- a/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts @@ -11,6 +11,11 @@ import { } from "@gsd/native"; import { getCustomThemesDir, getThemesDir } from "../../../config.js"; +// Issue #453: native preview highlighting can wedge the entire interactive +// session after a successful file tool. Keep the safer plain-text path as the +// default and allow native highlighting only as an explicit opt-in. +const NATIVE_TUI_HIGHLIGHT_ENABLED = process.env.GSD_ENABLE_NATIVE_TUI_HIGHLIGHT === "1"; + // ============================================================================ // Types & Schema // ============================================================================ @@ -955,6 +960,10 @@ function getHighlightColors(t: Theme): HighlightColors { * Returns array of highlighted lines. */ export function highlightCode(code: string, lang?: string): string[] { + if (!NATIVE_TUI_HIGHLIGHT_ENABLED) { + return code.split("\n"); + } + const validLang = lang && supportsLanguage(lang) ? lang : null; try { return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n"); @@ -1051,6 +1060,10 @@ export function getMarkdownTheme(): MarkdownTheme { underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), highlightCode: (code: string, lang?: string): string[] => { + if (!NATIVE_TUI_HIGHLIGHT_ENABLED) { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + const validLang = lang && supportsLanguage(lang) ? lang : null; try { return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n"); diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts index 8e859c3fe..7b2cc6d88 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts @@ -133,7 +133,7 @@ export async function runRpcMode(session: AgentSession): Promise { "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, ), - notify(message: string, type?: "info" | "warning" | "error"): void { + notify(message: string, type?: "info" | "warning" | "error" | "success"): void { // Fire and forget - no response needed output({ type: "extension_ui_request", diff --git a/packages/pi-tui/src/components/__tests__/cancellable-loader.test.ts b/packages/pi-tui/src/components/__tests__/cancellable-loader.test.ts new file mode 100644 index 000000000..4f7889402 --- /dev/null +++ b/packages/pi-tui/src/components/__tests__/cancellable-loader.test.ts @@ -0,0 +1,45 @@ +// pi-tui CancellableLoader component regression tests +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, it, mock, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { CancellableLoader } from "../cancellable-loader.js"; + +function makeMockTUI() { + return { requestRender: mock.fn() } as any; +} + +describe("CancellableLoader", () => { + let loader: CancellableLoader; + let tui: ReturnType; + + beforeEach(() => { + tui = makeMockTUI(); + }); + + afterEach(() => { + loader?.dispose(); + }); + + it("dispose() aborts the AbortController signal", () => { + loader = new CancellableLoader(tui, (s) => s, (s) => s, "test"); + assert.equal(loader.aborted, false); + loader.dispose(); + assert.equal(loader.aborted, true); + }); + + it("dispose() clears the onAbort callback", () => { + loader = new CancellableLoader(tui, (s) => s, (s) => s, "test"); + loader.onAbort = () => {}; + loader.dispose(); + assert.equal(loader.onAbort, undefined); + }); + + it("signal is aborted after dispose()", () => { + loader = new CancellableLoader(tui, (s) => s, (s) => s, "test"); + const signal = loader.signal; + assert.equal(signal.aborted, false); + loader.dispose(); + assert.equal(signal.aborted, true); + }); +}); diff --git a/packages/pi-tui/src/components/__tests__/input.test.ts b/packages/pi-tui/src/components/__tests__/input.test.ts new file mode 100644 index 000000000..c47100492 --- /dev/null +++ b/packages/pi-tui/src/components/__tests__/input.test.ts @@ -0,0 +1,35 @@ +// pi-tui Input component regression tests +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Input } from "../input.js"; + +describe("Input", () => { + it("paste buffer is cleared when focus is lost", () => { + const input = new Input(); + input.focused = true; + + // Simulate starting a paste (bracket paste start marker) + input.handleInput("\x1b[200~partial"); + + // Now lose focus mid-paste + input.focused = false; + + // Regain focus — should not have stale paste state + input.focused = true; + + // Typing normal text should work without paste buffer corruption + input.handleInput("hello"); + assert.equal(input.getValue(), "hello"); + }); + + it("focused getter/setter works correctly", () => { + const input = new Input(); + assert.equal(input.focused, false); + input.focused = true; + assert.equal(input.focused, true); + input.focused = false; + assert.equal(input.focused, false); + }); +}); diff --git a/packages/pi-tui/src/components/__tests__/loader.test.ts b/packages/pi-tui/src/components/__tests__/loader.test.ts new file mode 100644 index 000000000..9c22056fa --- /dev/null +++ b/packages/pi-tui/src/components/__tests__/loader.test.ts @@ -0,0 +1,45 @@ +// pi-tui Loader component regression tests +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, it, mock, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { Loader } from "../loader.js"; + +function makeMockTUI() { + return { requestRender: mock.fn() } as any; +} + +describe("Loader", () => { + let loader: Loader; + let tui: ReturnType; + + beforeEach(() => { + tui = makeMockTUI(); + }); + + afterEach(() => { + loader?.stop(); + }); + + it("start() is idempotent — calling twice does not leak intervals", () => { + loader = new Loader(tui, (s) => s, (s) => s, "test"); + // Constructor calls start() once, call it again + loader.start(); + // stop() should clear the interval cleanly without orphaned timers + loader.stop(); + }); + + it("dispose() stops the interval and nulls the TUI reference", () => { + loader = new Loader(tui, (s) => s, (s) => s, "test"); + loader.dispose(); + // After dispose, calling stop() again should be safe (no-op) + loader.stop(); + }); + + it("stop() is safe to call multiple times", () => { + loader = new Loader(tui, (s) => s, (s) => s, "test"); + loader.stop(); + loader.stop(); + loader.stop(); + }); +}); diff --git a/packages/pi-tui/src/components/cancellable-loader.ts b/packages/pi-tui/src/components/cancellable-loader.ts index 506b763de..e790659e1 100644 --- a/packages/pi-tui/src/components/cancellable-loader.ts +++ b/packages/pi-tui/src/components/cancellable-loader.ts @@ -35,6 +35,8 @@ export class CancellableLoader extends Loader { } dispose(): void { + this.abortController.abort(); + this.onAbort = undefined; this.stop(); } } diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 768439289..35508bc55 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -182,7 +182,7 @@ export class Editor implements Component, Focusable { private undoStack = new UndoStack(); private textVersion = 0; private cachedText: string | null = null; - private layoutCache: { width: number; textVersion: number; lines: LayoutLine[] } | null = null; + private layoutCache: { width: number; textVersion: number; cursorLine: number; cursorCol: number; lines: LayoutLine[] } | null = null; private visualLineMapCache: { width: number; textVersion: number; lines: VisualLine[] } | null = null; public onSubmit?: (text: string) => void; @@ -243,12 +243,14 @@ export class Editor implements Component, Focusable { private getLayoutLines(width: number): LayoutLine[] { const cached = this.layoutCache; - if (cached && cached.width === width && cached.textVersion === this.textVersion) { + if (cached && cached.width === width && cached.textVersion === this.textVersion + && cached.cursorLine === this.state.cursorLine && cached.cursorCol === this.state.cursorCol) { return cached.lines; } const lines = this.layoutText(width); - this.layoutCache = { width, textVersion: this.textVersion, lines }; + this.layoutCache = { width, textVersion: this.textVersion, lines, + cursorLine: this.state.cursorLine, cursorCol: this.state.cursorCol }; return lines; } @@ -730,8 +732,17 @@ export class Editor implements Component, Focusable { return; } - // Regular characters + // Regular characters — reject partial escape sequence remnants that can + // occur when event loop latency causes the StdinBuffer to split an escape + // sequence (e.g. \x1b flushed as ESC, then "[D" arrives as text). if (data.charCodeAt(0) >= 32) { + if (data[0] === "[" && data.length >= 2 && data.length <= 8) { + const last = data[data.length - 1]!; + // CSI navigation remnants: [A-F (arrows/home/end), [H, [Z (shift-tab), [~ (func keys) + if (/^[A-FHZ]$/.test(last) || last === "~") { + return; // Drop CSI remnant (e.g. "[D", "[C", "[5~") + } + } this.insertCharacter(data); } } @@ -2055,6 +2066,10 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.lastAutocompleteLookupPrefix = null; } + public dispose(): void { + this.clearAutocompleteDebounce(); + } + public isShowingAutocomplete(): boolean { return this.autocompleteState !== null; } diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts index 13714b138..627f3557c 100644 --- a/packages/pi-tui/src/components/input.ts +++ b/packages/pi-tui/src/components/input.ts @@ -23,7 +23,17 @@ export class Input implements Component, Focusable { public placeholder: string = ""; /** Focusable interface - set by TUI when focus changes */ - focused: boolean = false; + private _focused: boolean = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + if (!value) { + this.isInPaste = false; + this.pasteBuffer = ""; + } + } // Bracketed paste mode buffering private pasteBuffer: string = ""; diff --git a/packages/pi-tui/src/components/loader.ts b/packages/pi-tui/src/components/loader.ts index b071e8ee2..a55a2570c 100644 --- a/packages/pi-tui/src/components/loader.ts +++ b/packages/pi-tui/src/components/loader.ts @@ -26,6 +26,9 @@ export class Loader extends Text { } start() { + if (this.intervalId) { + clearInterval(this.intervalId); + } this.updateDisplay(); this.intervalId = setInterval(() => { this.currentFrame = (this.currentFrame + 1) % this.frames.length; @@ -40,6 +43,11 @@ export class Loader extends Text { } } + dispose() { + this.stop(); + this.ui = null; + } + setMessage(message: string) { this.message = message; this.updateDisplay(); diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 9f5cc17d9..52bb27ad3 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -112,7 +112,10 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + // 50ms matches xterm's default escapeCodeTimeout and gives enough headroom + // for escape sequences that arrive split across multiple stdin data events + // (e.g. \x1b arriving separately from [D due to event loop latency). + this.stdinBuffer = new StdinBuffer({ timeout: 50 }); // Kitty protocol response pattern: \x1b[?u const kittyResponsePattern = /^\x1b\[\?(\d+)u$/; diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 89537f1b3..c3e39acc5 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -441,6 +441,15 @@ export class TUI extends Container { stop(): void { this.stopped = true; + + // Dispose all overlays to stop any running timers + for (const entry of this.overlayStack) { + if ("dispose" in entry.component && typeof (entry.component as any).dispose === "function") { + (entry.component as any).dispose(); + } + } + this.overlayStack = []; + // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content diff --git a/src/cli.ts b/src/cli.ts index 0836cd9c5..db17cc1d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys } from './wizard.js' import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js' import { shouldRunOnboarding, runOnboarding } from './onboarding.js' +import chalk from 'chalk' import { checkForUpdates } from './update-check.js' // --------------------------------------------------------------------------- @@ -42,15 +43,10 @@ function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { return } - const yellow = '\x1b[33m' - const dim = '\x1b[2m' - const reset = '\x1b[0m' - const bold = '\x1b[1m' - process.stderr.write( - `[gsd] ${yellow}Version mismatch detected${reset}\n` + - `[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` + - `[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`, + `[gsd] ${chalk.yellow('Version mismatch detected')}\n` + + `[gsd] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`gsd\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` + + `[gsd] Run ${chalk.bold('npm install -g gsd-pi@latest')} or ${chalk.bold('gsd update')}, then try again.\n`, ) process.exit(1) } @@ -137,6 +133,15 @@ migratePiCredentials(authStorage) // Run onboarding wizard on first launch (no LLM provider configured) if (!isPrintMode && shouldRunOnboarding(authStorage)) { await runOnboarding(authStorage) + + // Clean up stdin state left by @clack/prompts. + // readline.emitKeypressEvents() adds a permanent data listener and + // readline.createInterface() may leave stdin paused. Remove stale + // listeners and pause stdin so the TUI can start with a clean slate. + process.stdin.removeAllListeners('data') + process.stdin.removeAllListeners('keypress') + if (process.stdin.setRawMode) process.stdin.setRawMode(false) + process.stdin.pause() } // Non-blocking update check — runs at most once per 24h, fire-and-forget @@ -144,6 +149,13 @@ if (!isPrintMode) { checkForUpdates().catch(() => {}) } +// Warn if terminal is too narrow for readable output +if (!isPrintMode && process.stdout.columns && process.stdout.columns < 40) { + process.stderr.write( + chalk.yellow(`[gsd] Terminal width is ${process.stdout.columns} columns (minimum recommended: 40). Output may be unreadable.\n`), + ) +} + const modelRegistry = new ModelRegistry(authStorage) const settingsManager = SettingsManager.create(agentDir) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 31c4ae528..ce5b68de3 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -22,6 +22,7 @@ const resourceVersionManifestName = 'managed-resources.json' interface ManagedResourceManifest { gsdVersion: string + syncedAt?: number } function isExtensionFile(name: string): boolean { @@ -102,7 +103,7 @@ function getBundledGsdVersion(): string { } function writeManagedResourceManifest(agentDir: string): void { - const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion() } + const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion(), syncedAt: Date.now() } writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest)) } @@ -115,6 +116,15 @@ export function readManagedResourceVersion(agentDir: string): string | null { } } +export function readManagedResourceSyncedAt(agentDir: string): number | null { + try { + const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest + return typeof manifest?.syncedAt === 'number' ? manifest.syncedAt : null + } catch { + return null + } +} + export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null { const managedVersion = readManagedResourceVersion(agentDir) if (!managedVersion) { diff --git a/src/resources/GSD-WORKFLOW.md b/src/resources/GSD-WORKFLOW.md index de43f7420..6ae9cc5b9 100644 --- a/src/resources/GSD-WORKFLOW.md +++ b/src/resources/GSD-WORKFLOW.md @@ -4,8 +4,8 @@ > > **When to read this:** At the start of any session working on GSD-managed work, or when loaded by `/gsd`. > -> **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. +> **After reading this, always read `.gsd/STATE.md` to find out what's next.** +> If the milestone has a `M###-CONTEXT.md`, read that too. If the active slice has an `S##-CONTEXT.md`, read that as well — these files contain project-specific decisions, reference paths, and implementation guidance that this generic methodology doc does not. --- @@ -13,13 +13,14 @@ 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. +1. **`.gsd/STATE.md`** — Where are we? What's the next action? +2. **`.gsd/milestones//M###-ROADMAP.md`** — What's the plan? Which slices are done? (`STATE.md` tells you which milestone is active) +3. **`.gsd/milestones//M###-CONTEXT.md`** — Milestone-level project decisions, reference paths, constraints. Read this before doing implementation work. +4. If a slice is active and has one, read **`S##-CONTEXT.md`** — Slice-specific decisions and constraints. +5. If a slice is active, read its **`S##-PLAN.md`** — Which tasks exist? Which are done? +6. 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. +Then do the thing `STATE.md` says to do next. --- @@ -41,32 +42,32 @@ All artifacts live in `.gsd/` at the project root: ``` .gsd/ - state.md # Dashboard — always read first - decisions.md # Append-only decisions register + STATE.md # Dashboard — always read first (derived cache; runtime, gitignored) + 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) + M001-ROADMAP.md # Milestone plan (checkboxes = state) + M001-CONTEXT.md # Optional: user decisions from discuss phase + M001-RESEARCH.md # Optional: codebase/tech research + M001-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) + S01-PLAN.md # Task decomposition for this slice + S01-CONTEXT.md # Optional: slice-level user decisions + S01-RESEARCH.md # Optional: slice-level research + S01-SUMMARY.md # Slice summary (written on completion) + S01-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 + T01-PLAN.md # Individual task plan + T01-SUMMARY.md # Task summary with frontmatter ``` --- ## File Format Reference -### `roadmap.md` +### `M###-ROADMAP.md` ```markdown # M001: Title of the Milestone @@ -93,7 +94,7 @@ All artifacts live in `.gsd/` at the project root: **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): +**Boundary Map** (required section in M###-ROADMAP.md): After the slices section, include a `## Boundary Map` that shows what each slice produces and consumes: @@ -123,7 +124,7 @@ The boundary map is a **planning artifact** — not runnable code. It: - Enables deterministic verification that slices actually connect - Gets updated during slice planning if new interfaces emerge -### `plan.md` (slice-level) +### `S##-PLAN.md` (slice-level) ```markdown # S01: Slice Title @@ -148,7 +149,7 @@ The boundary map is a **planning artifact** — not runnable code. It: - path/to/another.ts ``` -### `TNN-plan.md` (task-level) +### `T##-PLAN.md` (task-level) ```markdown # T01: Task Title @@ -188,7 +189,7 @@ Critical wiring between artifacts: **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` +### `STATE.md` ```markdown # GSD State @@ -209,10 +210,10 @@ Critical wiring between artifacts: Exact next thing to do. ``` -### `context.md` (from discuss phase) +### `M###-CONTEXT.md` / `S##-CONTEXT.md` (from discuss phase) ```markdown -# S01: Slice Title — Context +# M001: Milestone or Slice Title — Context **Gathered:** 2026-03-07 **Status:** Ready for planning @@ -228,7 +229,7 @@ Exact next thing to do. - Ideas that came up but belong in other slices ``` -### `decisions.md` (append-only register) +### `DECISIONS.md` (append-only register) ```markdown # Decisions Register @@ -265,7 +266,7 @@ 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. +**Produces:** `M###-CONTEXT.md` for milestone-level discussion or `S##-CONTEXT.md` for slice-level discussion. **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. @@ -273,18 +274,18 @@ Work flows through these phases. Each phase produces a file. 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`. +4. Write decisions to the appropriate context file (`M###-CONTEXT.md` or `S##-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. +**Produces:** `M###-RESEARCH.md` at milestone level or `S##-RESEARCH.md` at 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. +1. Read `M###-CONTEXT.md` and/or `S##-CONTEXT.md` if they exist — 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: @@ -324,24 +325,24 @@ The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expens ### Phase 3: Plan **Purpose:** Decompose work into context-window-sized tasks with must-haves. -**Produces:** `plan.md` + individual `T01-plan.md` files. +**Produces:** `S##-PLAN.md` + individual `T01-PLAN.md` files. **For a milestone (roadmap):** -1. Read `context.md`, `research.md`, and `.gsd/decisions.md` if they exist. +1. Read `M###-CONTEXT.md`, `M###-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. +4. Write `M###-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. +1. Read the slice's entry in `M###-ROADMAP.md` **and its boundary map section** — know what interfaces this slice must produce and consume. +2. Read `M###-CONTEXT.md`, `S##-CONTEXT.md`, `M###-RESEARCH.md`, `S##-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. +8. Write `S##-PLAN.md` and individual `T##-PLAN.md` files. ### Phase 4: Execute @@ -349,10 +350,10 @@ The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expens **Produces:** Code changes + `[DONE:n]` markers. **How to do it manually:** -1. Read the task's `TNN-plan.md`. +1. Read the task's `T##-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`. +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 @@ -400,7 +401,7 @@ When verification finds gaps, include a **Gaps** section with what's missing, im ### Phase 6: Summarize **Purpose:** Record what happened for downstream tasks. -**Produces:** `TNN-summary.md`, and when slice completes, `summary.md`. +**Produces:** `T##-SUMMARY.md`, and when slice completes, `S##-SUMMARY.md`. **Task summary format:** ```markdown @@ -421,7 +422,7 @@ key_decisions: patterns_established: - "Pattern name and where it lives" drill_down_paths: - - .gsd/milestones/M001/slices/S01/tasks/T01-plan.md + - .gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md duration: 15min verification_result: pass completed_at: 2026-03-07T16:00:00Z @@ -445,7 +446,7 @@ What differed from the plan and why (or "None"). 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`. +**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. @@ -454,16 +455,16 @@ The one-liner must be substantive: "JWT auth with refresh rotation using jose" n **Purpose:** Mark work done and move to the next thing. **After a task completes:** -1. Mark the task done in `plan.md` (checkbox). +1. Mark the task done in `S##-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`. +3. If slice is complete → write slice summary, mark slice done in `M###-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. +1. Write slice `S##-SUMMARY.md` (compresses all task summaries). +2. Write slice `S##-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 `M###-ROADMAP.md` as `[x]`. +4. Update `STATE.md` with new position. +5. Update milestone `M###-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. @@ -513,17 +514,17 @@ The EXACT first thing to do when resuming. Not vague. Specific. ## State Management -### `state.md` is a derived cache +### `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 +- `M###-ROADMAP.md` → which slices exist and which are done +- `S##-PLAN.md` → which tasks exist within a slice +- `T##-SUMMARY.md` → what happened during a task +- `S##-SUMMARY.md` and `M###-SUMMARY.md` → compressed slice and milestone outcomes -**Update `state.md`** after every significant action: +**Update `STATE.md`** after every significant action: - Active milestone/slice/task - Recent decisions (last 3-5) - Blockers @@ -611,9 +612,9 @@ Tasks completed: When planning or executing a task, load relevant prior context: -1. Check the current slice's `depends:[]` in `roadmap.md`. +1. Check the current slice's `depends:[]` in `M###-ROADMAP.md`. 2. Load summaries from those dependency slices. -3. Start with the **highest available level** — milestone `summary.md` first. +3. Start with the **highest available level** — milestone `M###-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. @@ -630,32 +631,33 @@ These are soft caps — exceed them when genuinely needed, but don't let summari ## Project-Specific Context -This methodology doc is generic. Project-specific guidance belongs in the milestone's `context.md`: +This methodology doc is generic. Project-specific guidance belongs in the milestone and slice context files: -- **`.gsd/milestones//context.md`** — Architecture decisions, reference file paths, per-slice doc reading guides, implementation constraints, and any project-specific protocols (worktrees, testing, etc.) +- **`.gsd/milestones//M###-CONTEXT.md`** — milestone-level architecture decisions, reference file paths, and implementation constraints +- **`.gsd/milestones//slices/S##/S##-CONTEXT.md`** — slice-level decisions, edge cases, and narrow implementation guidance when present -**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. +**Always read the active milestone's `M###-CONTEXT.md` before starting implementation work.** If the active slice also has `S##-CONTEXT.md`, read that too. These files tell 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? +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. +4. If starting fresh: read the active slice's `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. +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 run `/gsd` to pick up where you left off, or `/gsd auto` to resume in auto-execution mode." -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. +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/extensions/async-jobs/async-bash-tool.ts b/src/resources/extensions/async-jobs/async-bash-tool.ts index 328b0dcf2..a4f4f5cfa 100644 --- a/src/resources/extensions/async-jobs/async-bash-tool.ts +++ b/src/resources/extensions/async-jobs/async-bash-tool.ts @@ -71,7 +71,7 @@ export function createAsyncBashTool( "Check /jobs to see all running and recent background jobs.", ], parameters: schema, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const manager = getManager(); const cwd = getCwd(); const { command, timeout, label } = params; @@ -91,6 +91,7 @@ export function createAsyncBashTool( "Use `await_job` to get results when ready, or `cancel_job` to stop.", ].join("\n"), }], + details: undefined, }; }, }; diff --git a/src/resources/extensions/async-jobs/await-tool.ts b/src/resources/extensions/async-jobs/await-tool.ts index bab889e9a..b1b8c6214 100644 --- a/src/resources/extensions/async-jobs/await-tool.ts +++ b/src/resources/extensions/async-jobs/await-tool.ts @@ -24,7 +24,7 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti description: "Wait for background jobs to complete. Provide specific job IDs or omit to wait for the next job that finishes. Returns results of completed jobs.", parameters: schema, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const manager = getManager(); const { jobs: jobIds } = params; @@ -43,6 +43,7 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti if (notFound.length > 0 && watched.length === 0) { return { content: [{ type: "text", text: `No jobs found: ${notFound.join(", ")}` }], + details: undefined, }; } } else { @@ -50,6 +51,7 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti if (watched.length === 0) { return { content: [{ type: "text", text: "No running background jobs." }], + details: undefined, }; } } @@ -59,7 +61,7 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti if (running.length === 0) { const result = formatResults(watched); manager.acknowledgeDeliveries(watched.map((j) => j.id)); - return { content: [{ type: "text", text: result }] }; + return { content: [{ type: "text", text: result }], details: undefined }; } // Wait for at least one to complete @@ -75,7 +77,7 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti result += `\n\n**Still running:** ${stillRunning.map((j) => `${j.id} (${j.label})`).join(", ")}`; } - return { content: [{ type: "text", text: result }] }; + return { content: [{ type: "text", text: result }], details: undefined }; }, }; } diff --git a/src/resources/extensions/async-jobs/cancel-job-tool.ts b/src/resources/extensions/async-jobs/cancel-job-tool.ts index 99f450414..7932d47b3 100644 --- a/src/resources/extensions/async-jobs/cancel-job-tool.ts +++ b/src/resources/extensions/async-jobs/cancel-job-tool.ts @@ -16,7 +16,7 @@ export function createCancelJobTool(getManager: () => AsyncJobManager): ToolDefi label: "Cancel Background Job", description: "Cancel a running background job by its ID.", parameters: schema, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const manager = getManager(); const result = manager.cancel(params.job_id); @@ -28,6 +28,7 @@ export function createCancelJobTool(getManager: () => AsyncJobManager): ToolDefi return { content: [{ type: "text", text: messages[result] ?? `Unknown result: ${result}` }], + details: undefined, }; }, }; diff --git a/src/resources/extensions/async-jobs/index.ts b/src/resources/extensions/async-jobs/index.ts index b44d4f2c6..54f452140 100644 --- a/src/resources/extensions/async-jobs/index.ts +++ b/src/resources/extensions/async-jobs/index.ts @@ -62,7 +62,7 @@ export default function AsyncJobs(pi: ExtensionAPI) { "", truncatedOutput, ].join("\n"), - display: `Background job ${job.id} ${job.status}`, + display: true, }, { deliverAs: "followUp", triggerTurn: true }, ); @@ -92,7 +92,7 @@ export default function AsyncJobs(pi: ExtensionAPI) { pi.sendMessage({ customType: "async_jobs_list", content: "No async job manager active.", - display: "No jobs", + display: true, }); return; } @@ -126,7 +126,7 @@ export default function AsyncJobs(pi: ExtensionAPI) { pi.sendMessage({ customType: "async_jobs_list", content: lines.join("\n"), - display: `${running.length} running, ${completed.length} recent`, + display: true, }); }, }); diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index 0f8099009..e126c12f1 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -29,1231 +29,52 @@ import type { ExtensionContext, Theme, } from "@gsd/pi-coding-agent"; -import { - truncateHead, - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - getShellConfig, - sanitizeCommand, -} from "@gsd/pi-coding-agent"; import { Text, truncateToWidth, visibleWidth, - matchesKey, Key, } from "@gsd/pi-tui"; import { Type } from "@sinclair/typebox"; -import { spawn, spawnSync, 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"; import { shortcutDesc } from "../shared/terminal.js"; -import { createRequire } from "node:module"; -// ── Windows VT Input Restoration ──────────────────────────────────────────── -// Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT -// flag from the shared stdin console handle. Re-enable it after each child exits. - -let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null; -function restoreWindowsVTInput(): void { - if (process.platform !== "win32") return; - try { - if (!_vtHandles) { - const cjsRequire = createRequire(import.meta.url); - const koffi = cjsRequire("koffi"); - const k32 = koffi.load("kernel32.dll"); - const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); - const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); - const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); - const handle = GetStdHandle(-10); - _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; - } - const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; - const mode = new Uint32Array(1); - _vtHandles.GetConsoleMode(_vtHandles.handle, mode); - if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { - _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); - } - } catch { /* koffi not available on non-Windows */ } -} - -// ── Types ────────────────────────────────────────────────────────────────── - -type ProcessStatus = - | "starting" - | "ready" - | "error" - | "exited" - | "crashed"; - -type ProcessType = "server" | "build" | "test" | "watcher" | "generic" | "shell"; - -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; - /** Command history for shell-type sessions */ - commandHistory: string[]; - /** 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; - readyTimeout?: 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 { shell, args: shellArgs } = getShellConfig(); - // Shell sessions default to the user's shell if no command specified - const command = processType === "shell" && !opts.command ? shell : opts.command; - const proc = spawn(shell, [...shellArgs, sanitizeCommand(command)], { - cwd: opts.cwd, - stdio: ["pipe", "pipe", "pipe"], - env, - detached: process.platform !== "win32", - }); - - const bg: BgProcess = { - id, - label: opts.label || command.slice(0, 60), - 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, - commandHistory: [], - lineDedup: new Map(), - totalRawLines: 0, - envKeys: Object.keys(opts.env || {}), - restartCount: 0, - startConfig: { - command, - cwd: opts.cwd, - label: opts.label || command.slice(0, 60), - processType, - readyPattern: opts.readyPattern || null, - readyPort: opts.readyPort || null, - group: opts.group || null, - }, - }; - - addEvent(bg, { type: "started", detail: `Process started: ${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) => { - restoreWindowsVTInput(); - 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, opts.readyTimeout); - } - - // Shell sessions are ready immediately after spawn - if (bg.processType === "shell") { - setTimeout(() => { - if (bg.alive && bg.status === "starting") { - transitionToReady(bg, "Shell session initialized"); - } - }, 200); - } - - processes.set(id, bg); - return bg; -} - -// ── Port Probing Loop ────────────────────────────────────────────────────── - -function startPortProbing(bg: BgProcess, port: number, customTimeout?: number): void { - const timeout = customTimeout || DEFAULT_READY_TIMEOUT; - const interval = setInterval(async () => { - if (!bg.alive) { - clearInterval(interval); - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); - const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; - addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode } }); - return; - } - if (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 — transition to error state so the process - // doesn't stay in "starting" forever (fixes #428) - setTimeout(() => { - clearInterval(interval); - if (bg.alive && bg.status === "starting") { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); - const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; - bg.status = "error"; - addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } }); - pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`); - } - }, 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 (process.platform === "win32") { - // Windows: use taskkill /F /T to force-kill the entire process tree. - // process.kill(-pid) (Unix process groups) does not work on Windows. - if (bg.proc.pid) { - const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], { - timeout: 5000, - encoding: "utf-8", - }); - if (result.status !== 0 && result.status !== 128) { - // taskkill failed — try the direct kill as fallback - bg.proc.kill(sig); - } - } else { - bg.proc.kill(sig); - } - } else { - // Unix/macOS: kill the process group via negative PID - 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) { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { - ready: false, - detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`, - }; - } - if (bg.status === "error") { - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { - ready: false, - detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`, - }; - } - 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` }; - } - } - - const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); - const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; - return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}` }; -} - -// ── Query Shell Environment ──────────────────────────────────────────────── - -async function queryShellEnv( - bg: BgProcess, - timeout: number, - signal?: AbortSignal, -): Promise<{ cwd: string; env: Record; shell: string } | null> { - const sentinel = `__GSD_ENV_${randomUUID().slice(0, 8)}__`; - const startIndex = bg.output.length; - - const cmd = [ - `echo "${sentinel}_START"`, - `echo "CWD=$(pwd)"`, - `echo "SHELL=$SHELL"`, - `echo "PATH=$PATH"`, - `echo "VIRTUAL_ENV=$VIRTUAL_ENV"`, - `echo "NODE_ENV=$NODE_ENV"`, - `echo "HOME=$HOME"`, - `echo "USER=$USER"`, - `echo "NVM_DIR=$NVM_DIR"`, - `echo "GOPATH=$GOPATH"`, - `echo "CARGO_HOME=$CARGO_HOME"`, - `echo "PYTHONPATH=$PYTHONPATH"`, - `echo "${sentinel}_END"`, - ].join(" && "); - - bg.proc.stdin?.write(cmd + "\n"); - - const start = Date.now(); - while (Date.now() - start < timeout) { - if (signal?.aborted) return null; - if (!bg.alive) return null; - - const newEntries = bg.output.slice(startIndex); - const endIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_END`)); - if (endIdx >= 0) { - const startIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_START`)); - if (startIdx >= 0) { - const envLines = newEntries.slice(startIdx + 1, endIdx); - const env: Record = {}; - let cwd = ""; - let shell = ""; - - for (const entry of envLines) { - const match = entry.line.match(/^([A-Z_]+)=(.*)$/); - if (match) { - const [, key, value] = match; - if (key === "CWD") { - cwd = value; - } else if (key === "SHELL") { - shell = value; - } else if (value) { - env[key] = value; - } - } - } - - return { cwd, env, shell }; - } - } - - await new Promise(r => setTimeout(r, 100)); - } - - return null; -} - -// ── 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)" }; -} - -// ── Run on Session ───────────────────────────────────────────────────────── - -async function runOnSession( - bg: BgProcess, - command: string, - timeout: number, - signal?: AbortSignal, -): Promise<{ exitCode: number; output: string; timedOut: boolean }> { - const sentinel = randomUUID().slice(0, 8); - const startMarker = `__GSD_SENTINEL_${sentinel}_START__`; - const endMarker = `__GSD_SENTINEL_${sentinel}_END__`; - const exitVar = `__GSD_EXIT_${sentinel}__`; - - // Snapshot current output buffer position - const startIndex = bg.output.length; - - // Write the sentinel-wrapped command to stdin - const wrappedCommand = [ - `echo ${startMarker}`, - command, - `${exitVar}=$?`, - `echo ${endMarker} $${exitVar}`, - ].join("\n"); - bg.proc.stdin?.write(wrappedCommand + "\n"); - - const start = Date.now(); - while (Date.now() - start < timeout) { - if (signal?.aborted) { - const newEntries = bg.output.slice(startIndex); - return { exitCode: -1, output: newEntries.map(e => e.line).join("\n") || "(cancelled)", timedOut: false }; - } - - // Process died while waiting - if (!bg.alive) { - const newEntries = bg.output.slice(startIndex); - const lines = newEntries.map(e => e.line); - return { exitCode: bg.proc.exitCode ?? -1, output: lines.join("\n") || "(process exited)", timedOut: false }; - } - - const newEntries = bg.output.slice(startIndex); - for (let i = 0; i < newEntries.length; i++) { - if (newEntries[i].line.includes(endMarker)) { - // Parse exit code from the END sentinel line - const endLine = newEntries[i].line; - const exitMatch = endLine.match(new RegExp(`${endMarker}\\s+(\\d+)`)); - const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : -1; - - // Extract output between START and END sentinels - const outputLines: string[] = []; - let capturing = false; - for (let j = 0; j < newEntries.length; j++) { - if (newEntries[j].line.includes(startMarker)) { - capturing = true; - continue; - } - if (newEntries[j].line.includes(endMarker)) { - break; - } - if (capturing) { - outputLines.push(newEntries[j].line); - } - } - - return { exitCode, output: outputLines.join("\n"), timedOut: false }; - } - } - - await new Promise(r => setTimeout(r, 100)); - } - - // Timed out - const newEntries = bg.output.slice(startIndex); - const outputLines: string[] = []; - let capturing = false; - for (const entry of newEntries) { - if (entry.line.includes(startMarker)) { - capturing = true; - continue; - } - if (capturing) { - outputLines.push(entry.line); - } - } - return { exitCode: -1, output: outputLines.join("\n") || "(no output)", timedOut: true }; -} - -// ── 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) { - const ttl = bg.processType === "shell" ? DEAD_PROCESS_TTL * 6 : DEAD_PROCESS_TTL; - if (now - bg.startedAt > 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; -} +// ── Sub-module imports ───────────────────────────────────────────────────── + +import type { BgProcessInfo, ProcessType, ProcessStatus } from "./types.js"; +import { DEFAULT_READY_TIMEOUT } from "./types.js"; +import { + processes, + pendingAlerts, + startProcess, + killProcess, + restartProcess, + getInfo, + getGroupStatus, + pruneDeadProcesses, + cleanupAll, + persistManifest, + loadManifest, + pushAlert, +} from "./process-manager.js"; +import { + generateDigest, + getHighlights, + getOutput, + formatDigestText, +} from "./output-formatter.js"; +import { waitForReady } from "./readiness-detector.js"; +import { queryShellEnv, sendAndWait, runOnSession } from "./interaction.js"; +import { formatUptime, formatTokenCount } from "./utilities.js"; +import { BgManagerOverlay } from "./overlay.js"; + +// ── Re-exports for consumers ─────────────────────────────────────────────── + +export type { ProcessStatus, ProcessType, BgProcess, BgProcessInfo, OutputDigest, OutputLine, ProcessEvent } from "./types.js"; +export { processes, startProcess, killProcess, restartProcess, cleanupAll } from "./process-manager.js"; +export { generateDigest, getHighlights, getOutput, formatDigestText } from "./output-formatter.js"; +export { waitForReady, probePort } from "./readiness-detector.js"; +export { sendAndWait, runOnSession, queryShellEnv } from "./interaction.js"; +export { BgManagerOverlay } from "./overlay.js"; // ── Extension Entry Point ────────────────────────────────────────────────── @@ -1274,7 +95,7 @@ export default function (pi: ExtensionAPI) { process.on("SIGINT", signalCleanup); process.on("beforeExit", signalCleanup); - // ── Compaction Awareness: Survive Context Resets ─────────────────── + // ── Compaction Awareness: Survive Context Resets ─────────────── /** Build a compact state summary of all alive processes for context re-injection */ function buildProcessStateAlert(reason: string): void { @@ -1353,7 +174,7 @@ export default function (pi: ExtensionAPI) { const manifest = loadManifest(ctx.cwd); if (manifest.length > 0) { // Check which PIDs are still alive - const surviving: ProcessManifest[] = []; + const surviving: typeof manifest = []; for (const entry of manifest) { if (entry.pid) { try { @@ -2515,14 +1336,6 @@ export default function (pi: ExtensionAPI) { 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; @@ -2758,422 +1571,3 @@ export default function (pi: ExtensionAPI) { 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/bg-shell/interaction.ts b/src/resources/extensions/bg-shell/interaction.ts new file mode 100644 index 000000000..9fcac657d --- /dev/null +++ b/src/resources/extensions/bg-shell/interaction.ts @@ -0,0 +1,198 @@ +/** + * Expect-style interactions: send_and_wait, run on session, query shell environment. + */ + +import { randomUUID } from "node:crypto"; +import type { BgProcess } from "./types.js"; + +// ── Query Shell Environment ──────────────────────────────────────────────── + +export async function queryShellEnv( + bg: BgProcess, + timeout: number, + signal?: AbortSignal, +): Promise<{ cwd: string; env: Record; shell: string } | null> { + const sentinel = `__GSD_ENV_${randomUUID().slice(0, 8)}__`; + const startIndex = bg.output.length; + + const cmd = [ + `echo "${sentinel}_START"`, + `echo "CWD=$(pwd)"`, + `echo "SHELL=$SHELL"`, + `echo "PATH=$PATH"`, + `echo "VIRTUAL_ENV=$VIRTUAL_ENV"`, + `echo "NODE_ENV=$NODE_ENV"`, + `echo "HOME=$HOME"`, + `echo "USER=$USER"`, + `echo "NVM_DIR=$NVM_DIR"`, + `echo "GOPATH=$GOPATH"`, + `echo "CARGO_HOME=$CARGO_HOME"`, + `echo "PYTHONPATH=$PYTHONPATH"`, + `echo "${sentinel}_END"`, + ].join(" && "); + + bg.proc.stdin?.write(cmd + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) return null; + if (!bg.alive) return null; + + const newEntries = bg.output.slice(startIndex); + const endIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_END`)); + if (endIdx >= 0) { + const startIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_START`)); + if (startIdx >= 0) { + const envLines = newEntries.slice(startIdx + 1, endIdx); + const env: Record = {}; + let cwd = ""; + let shell = ""; + + for (const entry of envLines) { + const match = entry.line.match(/^([A-Z_]+)=(.*)$/); + if (match) { + const [, key, value] = match; + if (key === "CWD") { + cwd = value; + } else if (key === "SHELL") { + shell = value; + } else if (value) { + env[key] = value; + } + } + } + + return { cwd, env, shell }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + return null; +} + +// ── Send and Wait ────────────────────────────────────────────────────────── + +export 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)" }; +} + +// ── Run on Session ───────────────────────────────────────────────────────── + +export async function runOnSession( + bg: BgProcess, + command: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ exitCode: number; output: string; timedOut: boolean }> { + const sentinel = randomUUID().slice(0, 8); + const startMarker = `__GSD_SENTINEL_${sentinel}_START__`; + const endMarker = `__GSD_SENTINEL_${sentinel}_END__`; + const exitVar = `__GSD_EXIT_${sentinel}__`; + + // Snapshot current output buffer position + const startIndex = bg.output.length; + + // Write the sentinel-wrapped command to stdin + const wrappedCommand = [ + `echo ${startMarker}`, + command, + `${exitVar}=$?`, + `echo ${endMarker} $${exitVar}`, + ].join("\n"); + bg.proc.stdin?.write(wrappedCommand + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) { + const newEntries = bg.output.slice(startIndex); + return { exitCode: -1, output: newEntries.map(e => e.line).join("\n") || "(cancelled)", timedOut: false }; + } + + // Process died while waiting + if (!bg.alive) { + const newEntries = bg.output.slice(startIndex); + const lines = newEntries.map(e => e.line); + return { exitCode: bg.proc.exitCode ?? -1, output: lines.join("\n") || "(process exited)", timedOut: false }; + } + + const newEntries = bg.output.slice(startIndex); + for (let i = 0; i < newEntries.length; i++) { + if (newEntries[i].line.includes(endMarker)) { + // Parse exit code from the END sentinel line + const endLine = newEntries[i].line; + const exitMatch = endLine.match(new RegExp(`${endMarker}\\s+(\\d+)`)); + const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : -1; + + // Extract output between START and END sentinels + const outputLines: string[] = []; + let capturing = false; + for (let j = 0; j < newEntries.length; j++) { + if (newEntries[j].line.includes(startMarker)) { + capturing = true; + continue; + } + if (newEntries[j].line.includes(endMarker)) { + break; + } + if (capturing) { + outputLines.push(newEntries[j].line); + } + } + + return { exitCode, output: outputLines.join("\n"), timedOut: false }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + // Timed out + const newEntries = bg.output.slice(startIndex); + const outputLines: string[] = []; + let capturing = false; + for (const entry of newEntries) { + if (entry.line.includes(startMarker)) { + capturing = true; + continue; + } + if (capturing) { + outputLines.push(entry.line); + } + } + return { exitCode: -1, output: outputLines.join("\n") || "(no output)", timedOut: true }; +} diff --git a/src/resources/extensions/bg-shell/output-formatter.ts b/src/resources/extensions/bg-shell/output-formatter.ts new file mode 100644 index 000000000..044cf0068 --- /dev/null +++ b/src/resources/extensions/bg-shell/output-formatter.ts @@ -0,0 +1,259 @@ +/** + * Output analysis, digest generation, highlights extraction, and output retrieval. + */ + +import { + truncateHead, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, +} from "@gsd/pi-coding-agent"; +import type { BgProcess, OutputDigest, OutputLine, GetOutputOptions } from "./types.js"; +import { + ERROR_PATTERNS, + WARNING_PATTERNS, + URL_PATTERN, + PORT_PATTERN, + READINESS_PATTERNS, + BUILD_COMPLETE_PATTERNS, + TEST_RESULT_PATTERNS, +} from "./types.js"; +import { addEvent, pushAlert } from "./process-manager.js"; +import { transitionToReady } from "./readiness-detector.js"; +import { formatUptime, formatTimeAgo } from "./utilities.js"; + +// ── Output Analysis ──────────────────────────────────────────────────────── + +export 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); +} + +// ── Digest Generation ────────────────────────────────────────────────────── + +export 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 ─────────────────────────────────────────────────── + +export 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; +} + +// ── Output Retrieval (multi-tier) ────────────────────────────────────────── + +export 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; +} + +// ── Format Digest for LLM ────────────────────────────────────────────────── + +export 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; +} diff --git a/src/resources/extensions/bg-shell/overlay.ts b/src/resources/extensions/bg-shell/overlay.ts new file mode 100644 index 000000000..ed8c45c74 --- /dev/null +++ b/src/resources/extensions/bg-shell/overlay.ts @@ -0,0 +1,432 @@ +/** + * TUI: Background Process Manager Overlay. + */ + +import type { Theme } from "@gsd/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui"; +import type { BgProcess, ProcessStatus } from "./types.js"; +import { ERROR_PATTERNS, WARNING_PATTERNS } from "./types.js"; +import { formatUptime, formatTimeAgo } from "./utilities.js"; +import { + processes, + killProcess, + cleanupAll, + restartProcess, +} from "./process-manager.js"; + +export 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/bg-shell/process-manager.ts b/src/resources/extensions/bg-shell/process-manager.ts new file mode 100644 index 000000000..603ddba66 --- /dev/null +++ b/src/resources/extensions/bg-shell/process-manager.ts @@ -0,0 +1,404 @@ +/** + * Process lifecycle management: start, stop, restart, signal, state tracking, + * process registry, and persistence. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getShellConfig, sanitizeCommand } from "@gsd/pi-coding-agent"; +import type { + BgProcess, + BgProcessInfo, + ProcessEvent, + ProcessManifest, + ProcessType, + StartOptions, +} from "./types.js"; +import { + MAX_BUFFER_LINES, + MAX_EVENTS, + DEAD_PROCESS_TTL, +} from "./types.js"; +import { restoreWindowsVTInput, formatUptime } from "./utilities.js"; +import { analyzeLine } from "./output-formatter.js"; +import { startPortProbing, transitionToReady } from "./readiness-detector.js"; + +// ── Process Registry ─────────────────────────────────────────────────────── + +export const processes = new Map(); + +/** Pending alerts to inject into the next agent context */ +export let pendingAlerts: string[] = []; + +/** Replace the pendingAlerts array (used by the extension entry point) */ +export function setPendingAlerts(alerts: string[]): void { + pendingAlerts = alerts; +} + +export 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); + } +} + +export 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); + } +} + +export function pushAlert(bg: BgProcess, message: string): void { + pendingAlerts.push(`[bg:${bg.id} ${bg.label}] ${message}`); +} + +export 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 ───────────────────────────────────────────────── + +export 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"; +} + +// ── Process Start ────────────────────────────────────────────────────────── + +export 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 { shell, args: shellArgs } = getShellConfig(); + // Shell sessions default to the user's shell if no command specified + const command = processType === "shell" && !opts.command ? shell : opts.command; + const proc = spawn(shell, [...shellArgs, sanitizeCommand(command)], { + cwd: opts.cwd, + stdio: ["pipe", "pipe", "pipe"], + env, + detached: process.platform !== "win32", + }); + + const bg: BgProcess = { + id, + label: opts.label || command.slice(0, 60), + 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, + commandHistory: [], + lineDedup: new Map(), + totalRawLines: 0, + envKeys: Object.keys(opts.env || {}), + restartCount: 0, + startConfig: { + command, + cwd: opts.cwd, + label: opts.label || command.slice(0, 60), + processType, + readyPattern: opts.readyPattern || null, + readyPort: opts.readyPort || null, + group: opts.group || null, + }, + }; + + addEvent(bg, { type: "started", detail: `Process started: ${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) => { + restoreWindowsVTInput(); + 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, opts.readyTimeout); + } + + // Shell sessions are ready immediately after spawn + if (bg.processType === "shell") { + setTimeout(() => { + if (bg.alive && bg.status === "starting") { + transitionToReady(bg, "Shell session initialized"); + } + }, 200); + } + + processes.set(id, bg); + return bg; +} + +// ── Process Kill ─────────────────────────────────────────────────────────── + +export 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 (process.platform === "win32") { + // Windows: use taskkill /F /T to force-kill the entire process tree. + // process.kill(-pid) (Unix process groups) does not work on Windows. + if (bg.proc.pid) { + const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], { + timeout: 5000, + encoding: "utf-8", + }); + if (result.status !== 0 && result.status !== 128) { + // taskkill failed — try the direct kill as fallback + bg.proc.kill(sig); + } + } else { + bg.proc.kill(sig); + } + } else { + // Unix/macOS: kill the process group via negative PID + 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 ──────────────────────────────────────────────────────── + +export 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; +} + +// ── Group Operations ─────────────────────────────────────────────────────── + +export function getGroupProcesses(group: string): BgProcess[] { + return Array.from(processes.values()).filter(p => p.group === group); +} + +export function getGroupStatus(group: string): { + group: string; + healthy: boolean; + processes: { id: string; label: string; status: import("./types.js").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, + })), + }; +} + +// ── Cleanup ──────────────────────────────────────────────────────────────── + +export function pruneDeadProcesses(): void { + const now = Date.now(); + for (const [id, bg] of processes) { + if (!bg.alive) { + const ttl = bg.processType === "shell" ? DEAD_PROCESS_TTL * 6 : DEAD_PROCESS_TTL; + if (now - bg.startedAt > ttl) { + processes.delete(id); + } + } + } +} + +export function cleanupAll(): void { + for (const [id, bg] of processes) { + if (bg.alive) killProcess(id, "SIGKILL"); + } + processes.clear(); +} + +// ── Persistence ──────────────────────────────────────────────────────────── + +export function getManifestPath(cwd: string): string { + const dir = join(cwd, ".bg-shell"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return join(dir, "manifest.json"); +} + +export 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 */ } +} + +export function loadManifest(cwd: string): ProcessManifest[] { + try { + const path = getManifestPath(cwd); + if (existsSync(path)) { + return JSON.parse(readFileSync(path, "utf-8")); + } + } catch { /* best effort */ } + return []; +} diff --git a/src/resources/extensions/bg-shell/readiness-detector.ts b/src/resources/extensions/bg-shell/readiness-detector.ts new file mode 100644 index 000000000..e1e923fdc --- /dev/null +++ b/src/resources/extensions/bg-shell/readiness-detector.ts @@ -0,0 +1,126 @@ +/** + * Readiness detection: port probing, pattern matching, wait-for-ready. + */ + +import { createConnection } from "node:net"; +import type { BgProcess } from "./types.js"; +import { + PORT_PROBE_TIMEOUT, + READY_POLL_INTERVAL, + DEFAULT_READY_TIMEOUT, +} from "./types.js"; +import { addEvent, pushAlert } from "./process-manager.js"; + +// ── Readiness Transition ─────────────────────────────────────────────────── + +export function transitionToReady(bg: BgProcess, detail: string): void { + bg.status = "ready"; + bg.wasReady = true; + addEvent(bg, { type: "ready", detail }); +} + +// ── Port Probing ─────────────────────────────────────────────────────────── + +export 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); + }); + }); +} + +// ── Port Probing Loop ────────────────────────────────────────────────────── + +export function startPortProbing(bg: BgProcess, port: number, customTimeout?: number): void { + const timeout = customTimeout || DEFAULT_READY_TIMEOUT; + const interval = setInterval(async () => { + if (!bg.alive) { + clearInterval(interval); + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); + const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; + addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode } }); + return; + } + if (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 — transition to error state so the process + // doesn't stay in "starting" forever (fixes #428) + setTimeout(() => { + clearInterval(interval); + if (bg.alive && bg.status === "starting") { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line); + const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; + bg.status = "error"; + addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } }); + pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`); + } + }, timeout); +} + +// ── Wait for Ready ───────────────────────────────────────────────────────── + +export 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) { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { + ready: false, + detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`, + }; + } + if (bg.status === "error") { + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { + ready: false, + detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`, + }; + } + 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` }; + } + } + + const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line); + const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; + return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}` }; +} diff --git a/src/resources/extensions/bg-shell/types.ts b/src/resources/extensions/bg-shell/types.ts new file mode 100644 index 000000000..579e5b09b --- /dev/null +++ b/src/resources/extensions/bg-shell/types.ts @@ -0,0 +1,251 @@ +/** + * Shared types, constants, and pattern databases for the bg-shell extension. + */ + +// ── Types ────────────────────────────────────────────────────────────────── + +export type ProcessStatus = + | "starting" + | "ready" + | "error" + | "exited" + | "crashed"; + +export type ProcessType = "server" | "build" | "test" | "watcher" | "generic" | "shell"; + +export interface ProcessEvent { + type: + | "started" + | "ready" + | "error_detected" + | "recovered" + | "exited" + | "crashed" + | "output" + | "port_open" + | "pattern_match" + | "port_timeout"; + timestamp: number; + detail: string; + data?: Record; +} + +export interface OutputDigest { + status: ProcessStatus; + uptime: string; + errors: string[]; + warnings: string[]; + urls: string[]; + ports: number[]; + lastActivity: string; + outputLines: number; + changeSummary: string; +} + +export interface OutputLine { + stream: "stdout" | "stderr"; + line: string; + ts: number; +} + +export interface BgProcess { + id: string; + label: string; + command: string; + cwd: string; + startedAt: number; + proc: import("node:child_process").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; + /** Command history for shell-type sessions */ + commandHistory: string[]; + /** 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 }; +} + +export 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; +} + +export interface StartOptions { + command: string; + cwd: string; + label?: string; + type?: ProcessType; + readyPattern?: string; + readyPort?: number; + readyTimeout?: number; + group?: string; + env?: Record; +} + +export interface GetOutputOptions { + stream: "stdout" | "stderr" | "both"; + tail?: number; + filter?: string; + incremental?: boolean; +} + +export 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; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +export const MAX_BUFFER_LINES = 5000; +export const MAX_EVENTS = 200; +export const DEAD_PROCESS_TTL = 10 * 60 * 1000; +export const PORT_PROBE_TIMEOUT = 500; +export const READY_POLL_INTERVAL = 250; +export const DEFAULT_READY_TIMEOUT = 30000; + +// ── Pattern Databases ────────────────────────────────────────────────────── + +/** Patterns that indicate a process is ready/listening */ +export 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 */ +export 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 */ +export const WARNING_PATTERNS: RegExp[] = [ + /\bwarning\b[\s:[\](]/i, + /\bWARN(?:ING)?\b/, + /\bdeprecated\b/i, + /\bDEPRECATED\b/, + /⚠️?/, + /\[WARN\]/, +]; + +/** Patterns to extract URLs */ +export const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/gi; + +/** Patterns to extract port numbers from "listening" messages */ +export const PORT_PATTERN = /(?:port|listening\s+on|:)\s*(\d{2,5})\b/gi; + +/** Patterns indicating test results */ +export 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 */ +export 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, +]; diff --git a/src/resources/extensions/bg-shell/utilities.ts b/src/resources/extensions/bg-shell/utilities.ts new file mode 100644 index 000000000..b33c68b50 --- /dev/null +++ b/src/resources/extensions/bg-shell/utilities.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for the bg-shell extension. + */ + +import { createRequire } from "node:module"; + +// ── Windows VT Input Restoration ──────────────────────────────────────────── +// Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT +// flag from the shared stdin console handle. Re-enable it after each child exits. + +let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null; +export function restoreWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + if (!_vtHandles) { + const cjsRequire = createRequire(import.meta.url); + const koffi = cjsRequire("koffi"); + const k32 = koffi.load("kernel32.dll"); + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + const handle = GetStdHandle(-10); + _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; + } + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + const mode = new Uint32Array(1); + _vtHandles.GetConsoleMode(_vtHandles.handle, mode); + if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { + _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); + } + } catch { /* koffi not available on non-Windows */ } +} + +// ── Time Formatting ──────────────────────────────────────────────────────── + +export 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`; +} + +export function formatTimeAgo(timestamp: number): string { + return formatUptime(Date.now() - timestamp) + " ago"; +} + +export 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`; +} diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts index 7aef8fc47..fd235d121 100644 --- a/src/resources/extensions/gsd/activity-log.ts +++ b/src/resources/extensions/gsd/activity-log.ts @@ -8,10 +8,11 @@ * Diagnostic extraction is handled by session-forensics.ts. */ -import { writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from "node:fs"; -import { existsSync } from "node:fs"; +import { writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; + +const SEQ_PREFIX_RE = /^(\d+)-/; import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { gsdRoot } from "./paths.js"; @@ -26,10 +27,11 @@ function scanNextSequence(activityDir: string): number { let maxSeq = 0; try { for (const f of readdirSync(activityDir)) { - const match = f.match(/^(\d+)-/); + const match = f.match(SEQ_PREFIX_RE); if (match) maxSeq = Math.max(maxSeq, parseInt(match[1], 10)); } - } catch { + } catch (e) { + void e; /* directory not readable — start at 1 */ return 1; } return maxSeq + 1; @@ -55,14 +57,24 @@ function nextActivityFilePath( unitType: string, safeUnitId: string, ): string { - while (true) { + // Use O_CREAT | O_EXCL for atomic "create if absent" — no directory scan needed. + for (let attempts = 0; attempts < 1000; attempts++) { const seq = String(state.nextSeq).padStart(3, "0"); const filePath = join(activityDir, `${seq}-${unitType}-${safeUnitId}.jsonl`); - if (!existsSync(filePath)) { + try { + const fd = openSync(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + closeSync(fd); return filePath; + } catch (err: any) { + if (err?.code === "EEXIST") { + state.nextSeq++; + continue; + } + throw err; } - state.nextSeq = scanNextSequence(activityDir); } + // Fallback: should never reach here in practice + throw new Error(`Failed to find available activity log sequence in ${activityDir}`); } export function saveActivityLog( @@ -89,8 +101,9 @@ export function saveActivityLog( writeFileSync(filePath, content, "utf-8"); state.nextSeq += 1; state.lastSnapshotKeyByUnit.set(unitKey, key); - } catch { + } catch (e) { // Don't let logging failures break auto-mode + void e; } } @@ -99,7 +112,7 @@ export function pruneActivityLogs(activityDir: string, retentionDays: number): v const files = readdirSync(activityDir); const entries: { seq: number; filePath: string }[] = []; for (const f of files) { - const match = f.match(/^(\d+)-/); + const match = f.match(SEQ_PREFIX_RE); if (match) entries.push({ seq: parseInt(match[1], 10), filePath: join(activityDir, f) }); } if (entries.length === 0) return; diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 2131f3a7f..c2d9e41af 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -35,6 +35,10 @@ export interface AutoDashboardData { /** Running cost and token totals from metrics ledger */ totalCost: number; totalTokens: number; + /** Projected remaining cost based on unit-type averages (undefined if insufficient data) */ + projectedRemainingCost?: number; + /** Whether token profile has been auto-downgraded due to budget prediction */ + profileDowngraded?: boolean; } // ─── Unit Description Helpers ───────────────────────────────────────────────── @@ -49,6 +53,7 @@ export function unitVerb(unitType: string): string { case "execute-task": return "executing"; case "complete-slice": return "completing"; case "replan-slice": return "replanning"; + case "rewrite-docs": return "rewriting"; case "reassess-roadmap": return "reassessing"; case "run-uat": return "running UAT"; default: return unitType; @@ -65,6 +70,7 @@ export function unitPhaseLabel(unitType: string): string { case "execute-task": return "EXECUTE"; case "complete-slice": return "COMPLETE"; case "replan-slice": return "REPLAN"; + case "rewrite-docs": return "REWRITE"; case "reassess-roadmap": return "REASSESS"; case "run-uat": return "UAT"; default: return unitType.toUpperCase(); @@ -88,6 +94,7 @@ function peekNext(unitType: string, state: GSDState): string { case "execute-task": return `continue ${sid}`; case "complete-slice": return "reassess roadmap"; case "replan-slice": return `re-execute ${sid}`; + case "rewrite-docs": return "continue execution"; case "reassess-roadmap": return "advance to next slice"; case "run-uat": return "reassess roadmap"; default: return ""; @@ -265,6 +272,16 @@ export function updateProgressWidget( tui.requestRender(); }, 800); + // Refresh progress cache from disk every 5s so the widget reflects + // task/slice completion mid-unit. Without this, the progress bar only + // updates at dispatch time, appearing frozen during long-running units. + const progressRefreshTimer = mid ? setInterval(() => { + try { + updateSliceProgressCache(accessors.getBasePath(), mid.id, slice?.id); + cachedLines = undefined; + } catch { /* non-fatal */ } + }, 5_000) : null; + return { render(width: number): string[] { if (cachedLines && cachedWidth === width) return cachedLines; @@ -416,6 +433,7 @@ export function updateProgressWidget( }, dispose() { clearInterval(pulseTimer); + if (progressRefreshTimer) clearInterval(progressRefreshTimer); }, }; }); diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts new file mode 100644 index 000000000..a280a37c8 --- /dev/null +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -0,0 +1,294 @@ +/** + * Auto-mode Dispatch Table — declarative phase → unit mapping. + * + * Each rule maps a GSD state to the unit type, unit ID, and prompt builder + * that should be dispatched. Rules are evaluated in order; the first match wins. + * + * This replaces the 130-line if-else chain in dispatchNextUnit with a + * data structure that is inspectable, testable per-rule, and extensible + * without modifying orchestration code. + */ + +import type { GSDState } from "./types.js"; +import type { GSDPreferences } from "./preferences.js"; +import type { UatType } from "./files.js"; +import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; +import { + resolveMilestoneFile, resolveSliceFile, + relSliceFile, +} from "./paths.js"; +import { + buildResearchMilestonePrompt, + buildPlanMilestonePrompt, + buildResearchSlicePrompt, + buildPlanSlicePrompt, + buildExecuteTaskPrompt, + buildCompleteSlicePrompt, + buildCompleteMilestonePrompt, + buildReplanSlicePrompt, + buildRunUatPrompt, + buildReassessRoadmapPrompt, + buildRewriteDocsPrompt, + checkNeedsReassessment, + checkNeedsRunUat, +} from "./auto-prompts.js"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type DispatchAction = + | { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean } + | { action: "stop"; reason: string; level: "info" | "warning" | "error" } + | { action: "skip" }; + +export interface DispatchContext { + basePath: string; + mid: string; + midTitle: string; + state: GSDState; + prefs: GSDPreferences | undefined; +} + +interface DispatchRule { + /** Human-readable name for debugging and test identification */ + name: string; + /** Return a DispatchAction if this rule matches, null to fall through */ + match: (ctx: DispatchContext) => Promise; +} + +// ─── Rewrite Circuit Breaker ────────────────────────────────────────────── + +const MAX_REWRITE_ATTEMPTS = 3; +let rewriteAttemptCount = 0; +export function resetRewriteCircuitBreaker(): void { + rewriteAttemptCount = 0; +} + +// ─── Rules ──────────────────────────────────────────────────────────────── + +const DISPATCH_RULES: DispatchRule[] = [ + { + name: "rewrite-docs (override gate)", + match: async ({ mid, midTitle, state, basePath }) => { + const pendingOverrides = await loadActiveOverrides(basePath); + if (pendingOverrides.length === 0) return null; + if (rewriteAttemptCount >= MAX_REWRITE_ATTEMPTS) { + const { resolveAllOverrides } = await import("./files.js"); + await resolveAllOverrides(basePath); + rewriteAttemptCount = 0; + return null; + } + rewriteAttemptCount++; + const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid; + return { + action: "dispatch", + unitType: "rewrite-docs", + unitId, + prompt: await buildRewriteDocsPrompt(mid, midTitle, state.activeSlice, basePath, pendingOverrides), + }; + }, + }, + { + name: "summarizing → complete-slice", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "summarizing") return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + return { + action: "dispatch", + unitType: "complete-slice", + unitId: `${mid}/${sid}`, + prompt: await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, basePath), + }; + }, + }, + { + name: "run-uat (post-completion)", + match: async ({ state, mid, basePath, prefs }) => { + const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); + if (!needsRunUat) return null; + const { sliceId, uatType } = needsRunUat; + const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; + const uatContent = await loadFile(uatFile); + return { + action: "dispatch", + unitType: "run-uat", + unitId: `${mid}/${sliceId}`, + prompt: await buildRunUatPrompt( + mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, + ), + pauseAfterDispatch: uatType !== "artifact-driven", + }; + }, + }, + { + name: "reassess-roadmap (post-completion)", + match: async ({ state, mid, midTitle, basePath, prefs }) => { + // Phase skip: skip reassess when preference or profile says so + if (prefs?.phases?.skip_reassess) return null; + const needsReassess = await checkNeedsReassessment(basePath, mid, state); + if (!needsReassess) return null; + return { + action: "dispatch", + unitType: "reassess-roadmap", + unitId: `${mid}/${needsReassess.sliceId}`, + prompt: await buildReassessRoadmapPrompt(mid, midTitle, needsReassess.sliceId, basePath), + }; + }, + }, + { + name: "needs-discussion → stop", + match: async ({ state, mid, midTitle }) => { + if (state.phase !== "needs-discussion") return null; + return { + action: "stop", + reason: `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`, + level: "warning", + }; + }, + }, + { + name: "pre-planning (no context) → stop", + match: async ({ state, mid, basePath }) => { + if (state.phase !== "pre-planning") return null; + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (hasContext) return null; // fall through to next rule + return { + action: "stop", + reason: "No context or roadmap yet. Run /gsd to discuss first.", + level: "warning", + }; + }, + }, + { + name: "pre-planning (no research) → research-milestone", + match: async ({ state, mid, midTitle, basePath, prefs }) => { + if (state.phase !== "pre-planning") return null; + // Phase skip: skip research when preference or profile says so + if (prefs?.phases?.skip_research) return null; + const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + if (researchFile) return null; // has research, fall through + return { + action: "dispatch", + unitType: "research-milestone", + unitId: mid, + prompt: await buildResearchMilestonePrompt(mid, midTitle, basePath), + }; + }, + }, + { + name: "pre-planning (has research) → plan-milestone", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "pre-planning") return null; + return { + action: "dispatch", + unitType: "plan-milestone", + unitId: mid, + prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath), + }; + }, + }, + { + name: "planning (no research, not S01) → research-slice", + match: async ({ state, mid, midTitle, basePath, prefs }) => { + if (state.phase !== "planning") return null; + // Phase skip: skip research when preference or profile says so + if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); + if (researchFile) return null; // has research, fall through + // Skip slice research for S01 when milestone research already exists — + // the milestone research already covers the same ground for the first slice. + const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice + return { + action: "dispatch", + unitType: "research-slice", + unitId: `${mid}/${sid}`, + prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath), + }; + }, + }, + { + name: "planning → plan-slice", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "planning") return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + return { + action: "dispatch", + unitType: "plan-slice", + unitId: `${mid}/${sid}`, + prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + }; + }, + }, + { + name: "replanning-slice → replan-slice", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "replanning-slice") return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + return { + action: "dispatch", + unitType: "replan-slice", + unitId: `${mid}/${sid}`, + prompt: await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, basePath), + }; + }, + }, + { + name: "executing → execute-task", + match: async ({ state, mid, basePath }) => { + if (state.phase !== "executing" || !state.activeTask) return null; + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const tid = state.activeTask.id; + const tTitle = state.activeTask.title; + return { + action: "dispatch", + unitType: "execute-task", + unitId: `${mid}/${sid}/${tid}`, + prompt: await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath), + }; + }, + }, + { + name: "completing-milestone → complete-milestone", + match: async ({ state, mid, midTitle, basePath }) => { + if (state.phase !== "completing-milestone") return null; + return { + action: "dispatch", + unitType: "complete-milestone", + unitId: mid, + prompt: await buildCompleteMilestonePrompt(mid, midTitle, basePath), + }; + }, + }, +]; + +// ─── Resolver ───────────────────────────────────────────────────────────── + +/** + * Evaluate dispatch rules in order. Returns the first matching action, + * or a "stop" action if no rule matches (unhandled phase). + */ +export async function resolveDispatch(ctx: DispatchContext): Promise { + for (const rule of DISPATCH_RULES) { + const result = await rule.match(ctx); + if (result) return result; + } + + // No rule matched — unhandled phase + return { + action: "stop", + reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`, + level: "info", + }; +} + +/** Exposed for testing — returns the rule names in evaluation order. */ +export function getDispatchRuleNames(): string[] { + return DISPATCH_RULES.map(r => r.name); +} diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 301578b15..16d93713f 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -6,8 +6,8 @@ * utility. */ -import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType } from "./files.js"; -import type { UatType } from "./files.js"; +import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, loadActiveOverrides, formatOverridesSection } from "./files.js"; +import type { Override, UatType } from "./files.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; import { resolveMilestoneFile, resolveSliceFile, resolveSlicePath, @@ -15,8 +15,8 @@ import { relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, resolveGsdRootFile, relGsdRootFile, } from "./paths.js"; -import { resolveSkillDiscoveryMode } from "./preferences.js"; -import type { GSDState } from "./types.js"; +import { resolveSkillDiscoveryMode, resolveInlineLevel } from "./preferences.js"; +import type { GSDState, InlineLevel } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; import { join } from "node:path"; import { existsSync } from "node:fs"; @@ -202,6 +202,7 @@ export async function buildCarryForwardSection(priorSummaryPaths: string[], base 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 keyFiles = summary.frontmatter.key_files.slice(0, 3).join("; "); const diagnostics = extractMarkdownSection(content, "Diagnostics"); const parts = [summary.title || relPath]; @@ -209,6 +210,7 @@ export async function buildCarryForwardSection(priorSummaryPaths: string[], base if (provided) parts.push(`provides: ${provided}`); if (decisions) parts.push(`decisions: ${decisions}`); if (patterns) parts.push(`patterns: ${patterns}`); + if (keyFiles) parts.push(`key_files: ${keyFiles}`); if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); return `- \`${relPath}\` — ${parts.join(" | ")}`; @@ -381,6 +383,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); return loadPrompt("research-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, milestonePath: relMilestonePath(base, mid), contextPath: contextRel, @@ -390,7 +393,8 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string }); } -export async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise { +export async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string, level?: InlineLevel): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); const contextRel = relMilestoneFile(base, mid, "CONTEXT"); const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); @@ -403,23 +407,30 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba const { inlinePriorMilestoneSummary } = await import("./files.js"); const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); if (priorSummaryInline) inlined.push(priorSummaryInline); - const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); + const projectInline = inlineLevel !== "minimal" ? await inlineGsdRootFile(base, "project.md", "Project") : null; if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); + const requirementsInline = inlineLevel !== "minimal" ? await inlineGsdRootFile(base, "requirements.md", "Requirements") : null; if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); + const decisionsInline = inlineLevel !== "minimal" ? await inlineGsdRootFile(base, "decisions.md", "Decisions") : null; if (decisionsInline) inlined.push(decisionsInline); inlined.push(inlineTemplate("roadmap", "Roadmap")); - inlined.push(inlineTemplate("decisions", "Decisions")); - inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); - inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest")); + if (inlineLevel === "full") { + inlined.push(inlineTemplate("decisions", "Decisions")); + inlined.push(inlineTemplate("plan", "Slice Plan")); + inlined.push(inlineTemplate("task-plan", "Task Plan")); + inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest")); + } else if (inlineLevel === "standard") { + inlined.push(inlineTemplate("decisions", "Decisions")); + inlined.push(inlineTemplate("plan", "Slice Plan")); + inlined.push(inlineTemplate("task-plan", "Task Plan")); + } 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 secretsOutputPath = relMilestoneFile(base, mid, "SECRETS"); return loadPrompt("plan-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, milestonePath: relMilestonePath(base, mid), contextPath: contextRel, @@ -453,11 +464,15 @@ export async function buildResearchSlicePrompt( inlined.push(inlineTemplate("research", "Research")); const depContent = await inlineDependencySummaries(mid, sid, base); + const activeOverrides = await loadActiveOverrides(base); + const overridesInline = formatOverridesSection(activeOverrides); + if (overridesInline) inlined.unshift(overridesInline); 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"); return loadPrompt("research-slice", { + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, slicePath: relSlicePath(base, mid, sid), roadmapPath: roadmapRel, @@ -471,8 +486,9 @@ export async function buildResearchSlicePrompt( } export async function buildPlanSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel, ): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); @@ -482,19 +498,27 @@ export async function buildPlanSlicePrompt( 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); + if (inlineLevel !== "minimal") { + 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); + } inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); + if (inlineLevel === "full") { + inlined.push(inlineTemplate("task-plan", "Task Plan")); + } const depContent = await inlineDependencySummaries(mid, sid, base); + const planActiveOverrides = await loadActiveOverrides(base); + const planOverridesInline = formatOverridesSection(planActiveOverrides); + if (planOverridesInline) inlined.unshift(planOverridesInline); 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"); return loadPrompt("plan-slice", { + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, slicePath: relSlicePath(base, mid, sid), roadmapPath: roadmapRel, @@ -507,8 +531,9 @@ export async function buildPlanSlicePrompt( export async function buildExecuteTaskPrompt( mid: string, sid: string, sTitle: string, - tid: string, tTitle: string, base: string, + tid: string, tTitle: string, base: string, level?: InlineLevel, ): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base); const priorLines = priorSummaries.length > 0 @@ -548,15 +573,26 @@ export async function buildExecuteTaskPrompt( legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, ); - const carryForwardSection = await buildCarryForwardSection(priorSummaries, base); - const inlinedTemplates = [ - inlineTemplate("task-summary", "Task Summary"), - inlineTemplate("decisions", "Decisions"), - ].join("\n\n---\n\n"); + // For minimal inline level, only carry forward the most recent prior summary + const effectivePriorSummaries = inlineLevel === "minimal" && priorSummaries.length > 1 + ? priorSummaries.slice(-1) + : priorSummaries; + const carryForwardSection = await buildCarryForwardSection(effectivePriorSummaries, base); + const inlinedTemplates = inlineLevel === "minimal" + ? inlineTemplate("task-summary", "Task Summary") + : [ + inlineTemplate("task-summary", "Task Summary"), + inlineTemplate("decisions", "Decisions"), + ].join("\n\n---\n\n"); const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`; + const activeOverrides = await loadActiveOverrides(base); + const overridesSection = formatOverridesSection(activeOverrides); + return loadPrompt("execute-task", { + overridesSection, + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, planPath: relSliceFile(base, mid, sid, "PLAN"), slicePath: relSlicePath(base, mid, sid), @@ -572,8 +608,9 @@ export async function buildExecuteTaskPrompt( } export async function buildCompleteSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, + mid: string, _midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel, ): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); @@ -583,8 +620,10 @@ export async function buildCompleteSlicePrompt( 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); + if (inlineLevel !== "minimal") { + 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); @@ -601,7 +640,12 @@ export async function buildCompleteSlicePrompt( } } inlined.push(inlineTemplate("slice-summary", "Slice Summary")); - inlined.push(inlineTemplate("uat", "UAT")); + if (inlineLevel !== "minimal") { + inlined.push(inlineTemplate("uat", "UAT")); + } + const completeActiveOverrides = await loadActiveOverrides(base); + const completeOverridesInline = formatOverridesSection(completeActiveOverrides); + if (completeOverridesInline) inlined.unshift(completeOverridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -610,6 +654,7 @@ export async function buildCompleteSlicePrompt( const sliceUatPath = `${sliceRel}/${sid}-UAT.md`; return loadPrompt("complete-slice", { + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, slicePath: sliceRel, roadmapPath: roadmapRel, @@ -620,8 +665,9 @@ export async function buildCompleteSlicePrompt( } export async function buildCompleteMilestonePrompt( - mid: string, midTitle: string, base: string, + mid: string, midTitle: string, base: string, level?: InlineLevel, ): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); @@ -642,13 +688,15 @@ export async function buildCompleteMilestonePrompt( } } - // 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 root GSD files (skip for minimal — completion can read these if needed) + if (inlineLevel !== "minimal") { + 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"); @@ -661,6 +709,7 @@ export async function buildCompleteMilestonePrompt( const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`; return loadPrompt("complete-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, roadmapPath: roadmapRel, @@ -703,12 +752,16 @@ export async function buildReplanSlicePrompt( // Inline decisions const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); if (decisionsInline) inlined.push(decisionsInline); + const replanActiveOverrides = await loadActiveOverrides(base); + const replanOverridesInline = formatOverridesSection(replanActiveOverrides); + if (replanOverridesInline) inlined.unshift(replanOverridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`; return loadPrompt("replan-slice", { + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, @@ -742,6 +795,7 @@ export async function buildRunUatPrompt( const uatType = extractUatType(uatContent) ?? "human-experience"; return loadPrompt("run-uat", { + workingDirectory: base, milestoneId: mid, sliceId, uatPath, @@ -752,8 +806,9 @@ export async function buildRunUatPrompt( } export async function buildReassessRoadmapPrompt( - mid: string, midTitle: string, completedSliceId: string, base: string, + mid: string, midTitle: string, completedSliceId: string, base: string, level?: InlineLevel, ): Promise { + const inlineLevel = level ?? resolveInlineLevel(); const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY"); @@ -762,18 +817,21 @@ export async function buildReassessRoadmapPrompt( 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); + if (inlineLevel !== "minimal") { + 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 assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); return loadPrompt("reassess-roadmap", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, completedSliceId, @@ -783,3 +841,70 @@ export async function buildReassessRoadmapPrompt( inlinedContext, }); } + +export async function buildRewriteDocsPrompt( + mid: string, midTitle: string, + activeSlice: { id: string; title: string } | null, + base: string, + overrides: Override[], +): Promise { + const sid = activeSlice?.id; + const sTitle = activeSlice?.title ?? ""; + const docList: string[] = []; + + if (sid) { + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + if (slicePlanPath) { + docList.push(`- Slice plan: \`${slicePlanRel}\``); + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const planContent = await loadFile(slicePlanPath); + if (planContent) { + const plan = parsePlan(planContent); + for (const task of plan.tasks) { + if (!task.done) { + const taskPlanPath = resolveTaskFile(base, mid, sid, task.id, "PLAN"); + if (taskPlanPath) { + const taskRelPath = `${relSlicePath(base, mid, sid)}/tasks/${task.id}-PLAN.md`; + docList.push(`- Task plan: \`${taskRelPath}\``); + } + } + } + } + } + } + } + + const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); + if (existsSync(decisionsPath)) docList.push(`- Decisions: \`${relGsdRootFile("DECISIONS")}\``); + const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS"); + if (existsSync(requirementsPath)) docList.push(`- Requirements: \`${relGsdRootFile("REQUIREMENTS")}\``); + const projectPath = resolveGsdRootFile(base, "PROJECT"); + if (existsSync(projectPath)) docList.push(`- Project: \`${relGsdRootFile("PROJECT")}\``); + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + if (contextPath) docList.push(`- Milestone context (reference only): \`${contextRel}\``); + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) docList.push(`- Roadmap: \`${roadmapRel}\``); + + const overrideContent = overrides.map((o, i) => [ + `### Override ${i + 1}`, + `**Change:** ${o.change}`, + `**Issued:** ${o.timestamp}`, + `**During:** ${o.appliedAt}`, + ].join("\n")).join("\n\n"); + + const documentList = docList.length > 0 ? docList.join("\n") : "- No active plan documents found."; + + return loadPrompt("rewrite-docs", { + milestoneId: mid, + milestoneTitle: midTitle, + sliceId: sid ?? "none", + sliceTitle: sTitle, + overrideContent, + documentList, + overridesPath: relGsdRootFile("OVERRIDES"), + }); +} diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 6ac6c1dd5..4b304d356 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -11,7 +11,15 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { clearUnitRuntimeRecord, } from "./unit-runtime.js"; -import { runGit } from "./git-service.js"; +import { clearParseCache } from "./files.js"; +import { + nativeConflictFiles, + nativeCommit, + nativeCheckoutTheirs, + nativeAddPaths, + nativeMergeAbort, + nativeResetHard, +} from "./native-git-bridge.js"; import { resolveMilestonePath, resolveSlicePath, @@ -26,6 +34,7 @@ import { buildTaskFileName, resolveMilestoneFile, clearPathCache, + resolveGsdRootFile, } from "./paths.js"; import { parseRoadmap } from "./files.js"; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs"; @@ -78,6 +87,8 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba const dir = resolveMilestonePath(base, mid); return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; } + case "rewrite-docs": + return null; default: return null; } @@ -93,13 +104,24 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba * skipped writing the UAT file (see #176). */ export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean { - // Clear stale directory listing cache so artifact checks see fresh disk state (#431) - clearPathCache(); - // Hook units have no standard artifact — always pass. Their lifecycle // is managed by the hook engine, not the artifact verification system. if (unitType.startsWith("hook/")) return true; + // Clear stale directory listing cache AND parse cache so artifact checks see + // fresh disk state (#431). The parse cache must also be cleared because + // cacheKey() uses length + first/last 100 chars — when a checkbox changes + // from [ ] to [x], the key collides with the pre-edit version, returning + // stale parsed results (e.g., slice.done = false when it's actually true). + clearPathCache(); + clearParseCache(); + + if (unitType === "rewrite-docs") { + const overridesPath = resolveGsdRootFile(base, "OVERRIDES"); + if (!existsSync(overridesPath)) return true; + const content = readFileSync(overridesPath, "utf-8"); + return !content.includes("**Scope:** active"); + } const absPath = resolveExpectedArtifactPath(unitType, unitId, base); // Unit types with no verifiable artifact always pass (e.g. replan-slice). @@ -149,7 +171,12 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s const roadmap = parseRoadmap(roadmapContent); const slice = roadmap.slices.find(s => s.id === sid); if (slice && !slice.done) return false; - } catch { /* corrupt roadmap — be lenient and treat as verified */ } + } catch { + // Corrupt/unparseable roadmap — fail verification so the unit + // re-runs and has a chance to fix the roadmap. Silently passing + // here could advance past an incomplete slice. + return false; + } } } } @@ -201,6 +228,8 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`; case "replan-slice": return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`; + case "rewrite-docs": + return "Active overrides resolved in .gsd/OVERRIDES.md + plan documents updated"; case "reassess-roadmap": return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`; case "run-uat": @@ -251,6 +280,11 @@ export function skipExecuteTask( const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m"); if (re.test(planContent)) { writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8"); + } else { + // Regex didn't match — checkbox format differs from expected pattern. + // Return false so callers know the plan was NOT updated and can + // fall through to other recovery strategies instead of assuming success. + return false; } } } @@ -273,8 +307,9 @@ export function persistCompletedKey(base: string, key: string): void { if (existsSync(file)) { keys = JSON.parse(readFileSync(file, "utf-8")); } - } catch { /* corrupt file — start fresh */ } - if (!keys.includes(key)) { + } catch (e) { /* corrupt file — start fresh */ void e; } + const keySet = new Set(keys); + if (!keySet.has(key)) { keys.push(key); // Atomic write: tmp file + rename prevents partial writes on crash const tmpFile = file + ".tmp"; @@ -288,11 +323,17 @@ export function removePersistedKey(base: string, key: string): void { const file = completedKeysPath(base); try { if (existsSync(file)) { - let keys: string[] = JSON.parse(readFileSync(file, "utf-8")); - keys = keys.filter(k => k !== key); - writeFileSync(file, JSON.stringify(keys), "utf-8"); + const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); + const filtered = keys.filter(k => k !== key); + // Only write if the key was actually present + if (filtered.length !== keys.length) { + // Atomic write: tmp file + rename prevents partial writes on crash + const tmpFile = file + ".tmp"; + writeFileSync(tmpFile, JSON.stringify(filtered), "utf-8"); + renameSync(tmpFile, file); + } } - } catch { /* non-fatal */ } + } catch (e) { /* non-fatal: removePersistedKey failure */ void e; } } /** Load all completed unit keys from disk into the in-memory set. */ @@ -303,7 +344,7 @@ export function loadPersistedKeys(base: string, target: Set): void { const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); for (const k of keys) target.add(k); } - } catch { /* non-fatal */ } + } catch (e) { /* non-fatal: loadPersistedKeys failure */ void e; } } // ─── Merge State Reconciliation ─────────────────────────────────────────────── @@ -322,28 +363,66 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo const hasSquashMsg = existsSync(squashMsgPath); if (!hasMergeHead && !hasSquashMsg) return false; - const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); - if (!unmerged || !unmerged.trim()) { + const conflictedFiles = nativeConflictFiles(basePath); + if (conflictedFiles.length === 0) { // All conflicts resolved — finalize the merge/squash commit try { - runGit(basePath, ["commit", "--no-edit"], { allowFailure: false }); + nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder const mode = hasMergeHead ? "merge" : "squash commit"; ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info"); } catch { // Commit may already exist; non-fatal } } else { - // Still conflicted — abort and reset - if (hasMergeHead) { - runGit(basePath, ["merge", "--abort"], { allowFailure: true }); - } else if (hasSquashMsg) { - try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530) + const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/")); + const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/")); + + if (gsdConflicts.length > 0 && codeConflicts.length === 0) { + // All conflicts are in .gsd/ state files — auto-resolve by accepting theirs + let resolved = true; + try { + nativeCheckoutTheirs(basePath, gsdConflicts); + nativeAddPaths(basePath, gsdConflicts); + } catch { + resolved = false; + } + if (resolved) { + try { + nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts"); + ctx.ui.notify( + `Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, + "info", + ); + } catch { + resolved = false; + } + } + if (!resolved) { + if (hasMergeHead) { + try { nativeMergeAbort(basePath); } catch { /* best-effort */ } + } else if (hasSquashMsg) { + try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + } + try { nativeResetHard(basePath); } catch { /* best-effort */ } + ctx.ui.notify( + "Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", + "warning", + ); + } + } else { + // Code conflicts present — abort and reset + if (hasMergeHead) { + try { nativeMergeAbort(basePath); } catch { /* best-effort */ } + } else if (hasSquashMsg) { + try { unlinkSync(squashMsgPath); } catch { /* best-effort */ } + } + try { nativeResetHard(basePath); } catch { /* best-effort */ } + ctx.ui.notify( + "Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.", + "warning", + ); } - runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); - ctx.ui.notify( - "Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.", - "warning", - ); } return true; } @@ -370,8 +449,12 @@ export async function selfHealRuntimeRecords( const { unitType, unitId } = record; const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base); - // Case 1: Artifact exists — unit completed but closeout didn't finish - if (artifactPath && existsSync(artifactPath)) { + // Case 1: Artifact exists — unit completed but closeout didn't finish. + // Use verifyExpectedArtifact (not just existsSync) so that execute-task + // also checks the plan checkbox is marked [x]. Without this, a task + // whose summary exists but checkbox is unchecked would be incorrectly + // marked as completed, causing deriveState to re-dispatch it endlessly. + if (artifactPath && existsSync(artifactPath) && verifyExpectedArtifact(unitType, unitId, base)) { clearUnitRuntimeRecord(base, unitType, unitId); // Also persist completion key if missing const key = `${unitType}/${unitId}`; @@ -394,8 +477,9 @@ export async function selfHealRuntimeRecords( if (healed > 0) { ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info"); } - } catch { + } catch (e) { // Non-fatal — self-heal should never block auto-mode start + void e; } } diff --git a/src/resources/extensions/gsd/auto-supervisor.ts b/src/resources/extensions/gsd/auto-supervisor.ts index 742d30b91..05e0713fb 100644 --- a/src/resources/extensions/gsd/auto-supervisor.ts +++ b/src/resources/extensions/gsd/auto-supervisor.ts @@ -5,7 +5,7 @@ */ import { clearLock } from "./crash-recovery.js"; -import { execSync } from "node:child_process"; +import { nativeHasChanges } from "./native-git-bridge.js"; // ─── SIGTERM Handling ───────────────────────────────────────────────────────── @@ -47,12 +47,7 @@ export function deregisterSigtermHandler(handler: (() => void) | null): void { */ export function detectWorkingTreeActivity(cwd: string): boolean { try { - const out = execSync("git status --porcelain", { - cwd, - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }); - return out.toString().trim().length > 0; + return nativeHasChanges(cwd); } catch { return false; } diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index df0efb87c..b788e6a79 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -8,7 +8,7 @@ import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs"; import { join, resolve } from "node:path"; -import { execSync, execFileSync } from "node:child_process"; +import { execSync } from "node:child_process"; import { createWorktree, removeWorktree, @@ -19,6 +19,19 @@ import { } from "./git-service.js"; import { parseRoadmap } from "./files.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { + nativeGetCurrentBranch, + nativeWorkingTreeStatus, + nativeAddAll, + nativeCommit, + nativeCheckoutBranch, + nativeMergeSquash, + nativeConflictFiles, + nativeCheckoutTheirs, + nativeAddPaths, + nativeRmForce, + nativeBranchDelete, +} from "./native-git-bridge.js"; // ─── Module State ────────────────────────────────────────────────────────── @@ -60,18 +73,6 @@ function nudgeGitBranchCache(previousCwd: string): void { } } -function getCurrentBranch(cwd: string): string { - try { - return execSync("git branch --show-current", { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - } catch { - return ""; - } -} - // ─── Auto-Worktree Branch Naming ─────────────────────────────────────────── export function autoWorktreeBranch(milestoneId: string): string { @@ -176,7 +177,7 @@ export function isInAutoWorktree(basePath: string): boolean { const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath; const wtDir = join(resolvedBase, ".gsd", "worktrees"); if (!cwd.startsWith(wtDir)) return false; - const branch = getCurrentBranch(cwd); + const branch = nativeGetCurrentBranch(cwd); return branch.startsWith("milestone/"); } @@ -231,19 +232,11 @@ export function getAutoWorktreeOriginalBase(): string | null { */ function autoCommitDirtyState(cwd: string): boolean { try { - const status = execSync("git status --porcelain", { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); + const status = nativeWorkingTreeStatus(cwd); if (!status) return false; - execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "chore: auto-commit before milestone merge"], { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - return true; + nativeAddAll(cwd); + const result = nativeCommit(cwd, "chore: auto-commit before milestone merge"); + return result !== null; } catch { return false; } @@ -291,11 +284,7 @@ export function mergeMilestoneToMain( const mainBranch = prefs.main_branch || "main"; // 5. Checkout main - execSync(`git checkout ${mainBranch}`, { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); + nativeCheckoutBranch(originalBasePath_, mainBranch); // 6. Build rich commit message const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId; @@ -307,49 +296,48 @@ export function mergeMilestoneToMain( } const commitMessage = subject + body; - // 7. Squash merge - try { - execSync(`git merge --squash ${milestoneBranch}`, { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (mergeErr) { - // Check for real conflicts - try { - const conflictOutput = execSync("git diff --name-only --diff-filter=U", { - cwd: originalBasePath_, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - if (conflictOutput) { - const conflictedFiles = conflictOutput.split("\n").filter(Boolean); - throw new MergeConflictError(conflictedFiles, "squash", milestoneBranch, mainBranch); + // 7. Squash merge — auto-resolve .gsd/ state file conflicts (#530) + const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch); + + if (!mergeResult.success) { + // Check for conflicts — use merge result first, fall back to nativeConflictFiles + const conflictedFiles = mergeResult.conflicts.length > 0 + ? mergeResult.conflicts + : nativeConflictFiles(originalBasePath_); + + if (conflictedFiles.length > 0) { + // Separate .gsd/ state file conflicts from real code conflicts. + // GSD state files (STATE.md, completed-units.json, auto.lock, etc.) + // diverge between branches during normal operation — always prefer the + // milestone branch version since it has the latest execution state. + const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/")); + const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/")); + + // Auto-resolve .gsd/ conflicts by accepting the milestone branch version + if (gsdConflicts.length > 0) { + for (const gsdFile of gsdConflicts) { + try { + nativeCheckoutTheirs(originalBasePath_, [gsdFile]); + nativeAddPaths(originalBasePath_, [gsdFile]); + } catch { + // If checkout --theirs fails, try removing the file from the merge + // (it's a runtime file that shouldn't be committed anyway) + nativeRmForce(originalBasePath_, [gsdFile]); + } + } + } + + // If there are still non-.gsd conflicts, escalate + if (codeConflicts.length > 0) { + throw new MergeConflictError(codeConflicts, "squash", milestoneBranch, mainBranch); } - } catch (diffErr) { - if (diffErr instanceof MergeConflictError) throw diffErr; } // No conflicts detected — possibly "already up to date", fall through to commit } // 8. Commit (handle nothing-to-commit gracefully) - let nothingToCommit = false; - try { - execFileSync("git", ["commit", "-m", commitMessage], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (err: unknown) { - // execSync errors have stdout/stderr as properties -- check those for git's message - const errObj = err as { stdout?: string; stderr?: string; message?: string }; - const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" "); - if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) { - nothingToCommit = true; - } else { - throw err; - } - } + const commitResult = nativeCommit(originalBasePath_, commitMessage); + const nothingToCommit = commitResult === null; // 9. Auto-push if enabled let pushed = false; @@ -376,11 +364,7 @@ export function mergeMilestoneToMain( // 11. Delete milestone branch (after worktree removal so ref is unlocked) try { - execSync(`git branch -D ${milestoneBranch}`, { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); + nativeBranchDelete(originalBasePath_, milestoneBranch); } catch { // Best-effort } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 962e7a9ab..a2248847f 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,7 +18,7 @@ import type { import { deriveState, invalidateStateCache } from "./state.js"; import type { BudgetEnforcementMode, GSDState } from "./types.js"; -import { loadFile, parseRoadmap, getManifestStatus, clearParseCache } from "./files.js"; +import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js"; export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { @@ -27,8 +27,8 @@ import { relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, milestonesDir, buildMilestoneFileName, buildSliceFileName, buildTaskFileName, - clearPathCache, } from "./paths.js"; +import { invalidateAllCaches } from "./cache.js"; import { saveActivityLog } from "./activity-log.js"; import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js"; import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js"; @@ -39,7 +39,7 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences } from "./preferences.js"; +import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode } from "./preferences.js"; import { sendDesktopNotification } from "./notifications.js"; import type { GSDPreferences } from "./preferences.js"; import { @@ -68,8 +68,9 @@ import { } from "./metrics.js"; import { join } from "node:path"; import { sep as pathSep } from "node:path"; +import { homedir } from "node:os"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; -import { execSync, execFileSync } from "node:child_process"; +import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; import { autoCommitCurrentBranch, captureIntegrationBranch, @@ -80,7 +81,7 @@ import { parseSliceBranch, setActiveMilestoneId, } from "./worktree.js"; -import { GitServiceImpl, runGit } from "./git-service.js"; +import { GitServiceImpl } from "./git-service.js"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; import { formatGitError } from "./git-self-heal.js"; import { @@ -107,20 +108,7 @@ import { buildLoopRemediationSteps, reconcileMergeState, } from "./auto-recovery.js"; -import { - buildResearchMilestonePrompt, - buildPlanMilestonePrompt, - buildResearchSlicePrompt, - buildPlanSlicePrompt, - buildExecuteTaskPrompt, - buildCompleteSlicePrompt, - buildCompleteMilestonePrompt, - buildReplanSlicePrompt, - buildRunUatPrompt, - buildReassessRoadmapPrompt, - checkNeedsReassessment, - checkNeedsRunUat, -} from "./auto-prompts.js"; +import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js"; import { type AutoDashboardData, updateProgressWidget as _updateProgressWidget, @@ -169,6 +157,45 @@ const unitRecoveryCount = new Map(); /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */ const completedKeySet = new Set(); +/** Resource sync timestamp captured at auto-mode start. If the managed-resources + * manifest changes mid-session (e.g. /gsd:update or dev edit + copy-resources), + * templates on disk may expect variables the in-memory code doesn't provide. + * Detect this and stop gracefully instead of crashing. */ +let resourceSyncedAtOnStart: number | null = null; + +function readResourceSyncedAt(): number | null { + const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); + const manifestPath = join(agentDir, "managed-resources.json"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + return typeof manifest?.syncedAt === "number" ? manifest.syncedAt : null; + } catch { + return null; + } +} + +function checkResourcesStale(): string | null { + if (resourceSyncedAtOnStart === null) return null; + const current = readResourceSyncedAt(); + if (current === null) return null; + if (current !== resourceSyncedAtOnStart) { + return "GSD resources were updated since this session started. Restart gsd to load the new code."; + } + return null; +} + +/** + * Resolve whether auto-mode should use worktree isolation. + * Returns true for worktree mode (default), false for branch mode. + * Branch mode works directly in the project root — useful for repos + * with git submodules where worktrees don't work well (#531). + */ +function shouldUseWorktreeIsolation(): boolean { + const prefs = loadEffectiveGSDPreferences()?.preferences?.git; + if (prefs?.isolation === "branch") return false; + return true; // default: worktree +} + /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */ let pendingCrashRecovery: string | null = null; @@ -266,6 +293,41 @@ export function isAutoPaused(): boolean { return paused; } +/** + * Return the base path to use for the auto.lock file. + * Always uses the original project root (not the worktree) so that + * a second terminal can discover and stop a running auto-mode session. + */ +function lockBase(): string { + return originalBasePath || basePath; +} + +/** + * Attempt to stop a running auto-mode session from a different process. + * Reads the lock file at the project root, checks if the PID is alive, + * and sends SIGTERM to gracefully stop it. + * + * Returns true if a remote session was found and signaled, false otherwise. + */ +export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: number; error?: string } { + const lock = readCrashLock(projectRoot); + if (!lock) return { found: false }; + + if (!isLockProcessAlive(lock)) { + // Stale lock — clean it up + clearLock(projectRoot); + return { found: false }; + } + + // Send SIGTERM — the auto-mode process has a handler that clears the lock and exits + try { + process.kill(lock.pid, "SIGTERM"); + return { found: true, pid: lock.pid }; + } catch (err) { + return { found: false, error: (err as Error).message }; + } +} + export function isStepMode(): boolean { return stepMode; } @@ -325,6 +387,18 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void "error", ); await stopAuto(ctx, pi); + return; + } + + // If dispatchNextUnit returned normally but still didn't dispatch a unit + // (no sendMessage called → no timeout set), auto-mode is permanently + // stalled. Stop cleanly instead of leaving it active but idle (#537). + if (active && !unitTimeoutHandle && !wrapupWarningHandle) { + ctx.ui.notify( + "Auto-mode stalled — no dispatchable unit found after retry. Stopping. Run /gsd auto to restart.", + "warning", + ); + await stopAuto(ctx, pi); } }, DISPATCH_GAP_TIMEOUT_MS); } @@ -332,7 +406,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise { if (!active && !paused) return; clearUnitTimeout(); - if (basePath) clearLock(basePath); + if (lockBase()) clearLock(lockBase()); clearSkillSnapshot(); _dispatching = false; _skipDepth = 0; @@ -415,7 +489,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise { if (!active) return; clearUnitTimeout(); - if (basePath) clearLock(basePath); + if (lockBase()) clearLock(lockBase()); // Remove SIGTERM handler registered at auto-mode start deregisterSigtermHandler(); @@ -464,7 +538,8 @@ export async function startAuto( // ── Auto-worktree: re-enter worktree on resume if not already inside ── // Skip if already inside a worktree (manual /worktree) to prevent nesting. - if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) { + // Skip entirely in branch isolation mode (#531). + if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) { try { const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId); if (existingWtPath) { @@ -487,8 +562,8 @@ export async function startAuto( } } - // Re-register SIGTERM handler for the resumed session - registerSigtermHandler(basePath); + // Re-register SIGTERM handler for the resumed session (use original base for lock) + registerSigtermHandler(lockBase()); ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); @@ -505,34 +580,33 @@ export async function startAuto( } catch { /* non-fatal */ } // Self-heal: clear stale runtime records where artifacts already exist await selfHealRuntimeRecords(base, ctx, completedKeySet); - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); await dispatchNextUnit(ctx, pi); return; } // Ensure git repo exists — GSD needs it for worktree isolation - try { - execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" }); - } catch { + if (!nativeIsRepo(base)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; - execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" }); + nativeInit(base, mainBranch); } // Ensure .gitignore has baseline patterns - ensureGitignore(base); + const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs; + ensureGitignore(base, { commitDocs }); untrackRuntimeFiles(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 */ } + // Only commit .gsd/ init when commit_docs is not explicitly false + if (commitDocs !== false) { + try { + nativeAddPaths(base, [".gsd", ".gitignore"]); + nativeCommit(base, "chore: init gsd"); + } catch { /* nothing to commit */ } + } } // Initialize GitServiceImpl — basePath is set and git repo confirmed @@ -608,6 +682,7 @@ export async function startAuto( resetHookState(); restoreHookState(base); autoStartTime = Date.now(); + resourceSyncedAtOnStart = readResourceSyncedAt(); completedUnits = []; currentUnit = null; currentMilestoneId = state.activeMilestone?.id ?? null; @@ -622,7 +697,7 @@ export async function startAuto( // of the repo's default (main/master). Idempotent when the branch is the // same; updates the record when started from a different branch (#300). if (currentMilestoneId) { - captureIntegrationBranch(base, currentMilestoneId); + captureIntegrationBranch(base, currentMilestoneId, { commitDocs }); setActiveMilestoneId(base, currentMilestoneId); } @@ -643,7 +718,7 @@ export async function startAuto( return p.endsWith(worktreesSuffix); }; - if (currentMilestoneId && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { + if (currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { try { const existingWtPath = getAutoWorktreePath(base, currentMilestoneId); if (existingWtPath) { @@ -659,8 +734,8 @@ export async function startAuto( gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info"); } - // Re-register SIGTERM handler with the new basePath - registerSigtermHandler(basePath); + // Re-register SIGTERM handler with the original basePath (lock lives there) + registerSigtermHandler(originalBasePath); } catch (err) { // Worktree creation is non-fatal — continue in the project root. ctx.ui.notify( @@ -776,11 +851,9 @@ export async function handleAgentEnd( // Unit completed — clear its timeout clearUnitTimeout(); - // Invalidate deriveState() cache — the unit just completed and may have + // Invalidate all caches — the unit just completed and may have // written planning files (task summaries, roadmap checkboxes, etc.) - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); // Small delay to let files settle (git commits, file writes) await new Promise(r => setTimeout(r, 500)); @@ -819,6 +892,17 @@ export async function handleAgentEnd( // Non-fatal } + // ── Rewrite-docs completion: resolve overrides and reset circuit breaker ── + if (currentUnit.type === "rewrite-docs") { + try { + await resolveAllOverrides(basePath); + resetRewriteCircuitBreaker(); + ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); + } catch { + // Non-fatal — verifyExpectedArtifact will catch unresolved overrides + } + } + // ── Path A fix: verify artifact and persist completion before re-entering dispatch ── // After doctor + rebuildState, check whether the just-completed unit actually // produced its expected artifact. If so, persist the completion key now so the @@ -907,7 +991,7 @@ export async function handleAgentEnd( return; } const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile); + writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile); // Persist hook state so cycle counts survive crashes persistHookState(basePath); @@ -1133,11 +1217,22 @@ async function dispatchNextUnit( await new Promise(r => setTimeout(r, 200)); } - // Clear stale directory listing cache so deriveState sees fresh disk state (#431) - clearPathCache(); - // Clear parsed roadmap/plan cache — doctor may have re-populated it with + // Resource version guard: detect mid-session resource updates. + // Templates are read from disk on each dispatch but extension code is loaded + // once at startup. If resources were re-synced (e.g. /gsd:update, npm update, + // or dev copy-resources), templates may expect variables the in-memory code + // doesn't provide. Stop gracefully instead of crashing. + const staleMsg = checkResourcesStale(); + if (staleMsg) { + await stopAuto(ctx, pi); + ctx.ui.notify(staleMsg, "error"); + return; + } + + // Clear all caches so deriveState sees fresh disk state (#431). + // Parse cache is also cleared — doctor may have re-populated it with // stale data between handleAgentEnd and this dispatch call (Path B fix). - clearParseCache(); + invalidateAllCaches(); let state = await deriveState(basePath); let mid = state.activeMilestone?.id; @@ -1155,7 +1250,7 @@ async function dispatchNextUnit( unitRecoveryCount.clear(); unitLifetimeDispatches.clear(); // Capture integration branch for the new milestone and update git service - captureIntegrationBranch(originalBasePath || basePath, mid); + captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); } if (mid) { currentMilestoneId = mid; @@ -1184,9 +1279,7 @@ async function dispatchNextUnit( // ── Mid-merge safety check: detect leftover merge state from a prior session ── if (reconcileMergeState(basePath, ctx)) { - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); state = await deriveState(basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; @@ -1224,6 +1317,7 @@ async function dispatchNextUnit( if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) { try { const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP"); + if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${currentMilestoneId}`); const roadmapContent = readFileSync(roadmapPath, "utf-8"); const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent); basePath = originalBasePath; @@ -1309,7 +1403,7 @@ async function dispatchNextUnit( const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default if (contextThreshold > 0 && cmdCtx) { const contextUsage = cmdCtx.getContextUsage(); - if (contextUsage && contextUsage.percent >= contextThreshold) { + if (contextUsage && contextUsage.percent !== null && contextUsage.percent >= contextThreshold) { const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning"); sendDesktopNotification("GSD", `Context ${contextUsage.percent}% — paused`, "warning", "attention"); @@ -1347,144 +1441,34 @@ async function dispatchNextUnit( await runSecretsGate(); - 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; + // ── Dispatch table: resolve phase → unit type + prompt ── + const dispatchResult = await resolveDispatch({ + basePath, mid, midTitle: midTitle!, state, prefs, + }); - // ── Phase-first dispatch: complete-slice MUST run before reassessment ── - // If the current phase is "summarizing", complete-slice is responsible for - // complete-slice must run before reassessment. - if (state.phase === "summarizing") { - 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 { - // ── Adaptive Replanning: check if last completed slice needs reassessment ── - // Computed here (after summarizing guard) so complete-slice always runs first. - 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 === "needs-discussion") { - // Draft milestone — pause auto-mode and notify user. - // This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion - // where the user chose "Needs own discussion". Auto-mode cannot proceed because - // the draft is seed material, not a finalized context — planning requires a - // dedicated discussion first. - await stopAuto(ctx, pi); - ctx.ui.notify( - `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`, - "warning", - ); - return; - - } 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; - - 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; - - if (!hasResearch) { - // Skip slice research for S01 when milestone research already exists — - // the milestone research already covers the same ground for the first slice. - const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); - const hasMilestoneResearch = !!milestoneResearchFile; - if (hasMilestoneResearch && sid === "S01") { - unitType = "plan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - } else { - 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 === "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(`Unhandled phase "${state.phase}" — run /gsd doctor to diagnose.`, "info"); - return; + if (dispatchResult.action === "stop") { + 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(dispatchResult.reason, dispatchResult.level); + return; } + if (dispatchResult.action !== "dispatch") { + // skip action — yield and re-dispatch + await new Promise(r => setImmediate(r)); + await dispatchNextUnit(ctx, pi); + return; + } + + unitType = dispatchResult.unitType; + unitId = dispatchResult.unitId; + prompt = dispatchResult.prompt; + let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; + // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ── const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath); if (preDispatchResult.firedHooks.length > 0) { @@ -1805,15 +1789,15 @@ async function dispatchNextUnit( 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. + // Branchless architecture: all work commits sequentially on the milestone + // branch — no per-slice branches or slice-level merges. Milestone merge + // happens when phase === "complete" (see mergeMilestoneToMain above). // 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); + writeLock(lockBase(), unitType, unitId, completedUnits.length, sessionFile); // On crash recovery, prepend the full recovery briefing // On retry (stuck detection), prepend deep diagnostic from last attempt diff --git a/src/resources/extensions/gsd/cache.ts b/src/resources/extensions/gsd/cache.ts new file mode 100644 index 000000000..0dcef5b4f --- /dev/null +++ b/src/resources/extensions/gsd/cache.ts @@ -0,0 +1,27 @@ +// GSD Extension — Unified Cache Invalidation +// +// Three module-scoped caches exist across the GSD extension: +// 1. State cache (state.ts) — memoized deriveState() result +// 2. Path cache (paths.ts) — directory listing results (readdirSync) +// 3. Parse cache (files.ts) — parsed markdown file results +// +// After any file write that changes .gsd/ contents, all three must be +// invalidated together to prevent stale reads. This module provides a +// single function that clears all three atomically. + +import { invalidateStateCache } from './state.js'; +import { clearPathCache } from './paths.js'; +import { clearParseCache } from './files.js'; + +/** + * Invalidate all GSD runtime caches in one call. + * + * Call this after file writes, milestone transitions, merge reconciliation, + * or any operation that changes .gsd/ contents on disk. Forgetting to clear + * any single cache causes stale reads (see #431). + */ +export function invalidateAllCaches(): void { + invalidateStateCache(); + clearPathCache(); + clearParseCache(); +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 1c130f7f9..a2a86e89a 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url"; import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; import { showQueue, showDiscuss } from "./guided-flow.js"; -import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js"; +import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js"; import { getGlobalGSDPreferencesPath, getLegacyGlobalGSDPreferencesPath, @@ -22,7 +22,7 @@ import { loadEffectiveGSDPreferences, resolveAllSkillReferences, } from "./preferences.js"; -import { loadFile, saveFile } from "./files.js"; +import { loadFile, saveFile, appendOverride } from "./files.js"; import { formatDoctorIssuesForPrompt, formatDoctorReport, @@ -31,11 +31,12 @@ import { filterDoctorIssues, } from "./doctor.js"; import { loadPrompt } from "./prompt-loader.js"; -import { handleMigrate } from "./migrate/command.js"; + import { handleRemote } from "../remote-questions/remote-command.js"; import { handleHistory } from "./history.js"; import { handleUndo } from "./undo.js"; import { handleExport } from "./export.js"; +import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; 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"); @@ -57,12 +58,12 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote", + description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer", getArgumentCompletions: (prefix: string) => { const subcommands = [ "next", "auto", "stop", "pause", "status", "queue", "discuss", "history", "undo", "skip", "export", "cleanup", "prefs", - "config", "hooks", "doctor", "migrate", "remote", + "config", "hooks", "doctor", "migrate", "remote", "steer", ]; const parts = prefix.trim().split(/\s+/); @@ -177,7 +178,15 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (trimmed === "stop") { if (!isAutoActive() && !isAutoPaused()) { - ctx.ui.notify("Auto-mode is not running.", "info"); + // Not running in this process — check for a remote auto-mode session + const result = stopAutoRemote(process.cwd()); + if (result.found) { + ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info"); + } else if (result.error) { + ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error"); + } else { + ctx.ui.notify("Auto-mode is not running.", "info"); + } return; } await stopAuto(ctx, pi); @@ -248,7 +257,17 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed.startsWith("steer ")) { + await handleSteer(trimmed.replace(/^steer\s+/, "").trim(), ctx, pi); + return; + } + if (trimmed === "steer") { + ctx.ui.notify("Usage: /gsd steer . Example: /gsd steer Use Postgres instead of SQLite", "warning"); + return; + } + if (trimmed === "migrate" || trimmed.startsWith("migrate ")) { + const { handleMigrate } = await import("./migrate/command.js"); await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi); return; } @@ -265,7 +284,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote.`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer .`, "warning", ); }, @@ -416,7 +435,7 @@ async function handlePrefsWizard( const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`; const choice = await ctx.ui.select(title, modelOptions); - if (choice && choice !== "(keep current)") { + if (choice && typeof choice === "string" && choice !== "(keep current)") { if (choice === "(clear)") { delete models[phase]; } else { @@ -491,6 +510,16 @@ async function handlePrefsWizard( delete git.main_branch; } } + // ─── Git commit_docs ──────────────────────────────────────────────────── + const currentCommitDocs = git.commit_docs; + const commitDocsChoice = await ctx.ui.select( + `Track .gsd/ planning docs in git${currentCommitDocs !== undefined ? ` (current: ${currentCommitDocs})` : ""}:`, + ["true", "false", "(keep current)"], + ); + if (commitDocsChoice && commitDocsChoice !== "(keep current)") { + git.commit_docs = commitDocsChoice === "true"; + } + if (Object.keys(git).length > 0) { prefs.git = git; } @@ -627,7 +656,12 @@ function serializePreferencesToFrontmatter(prefs: Record): stri // ─── Tool Config Wizard ─────────────────────────────────────────────────────── -const TOOL_KEYS = [ +/** + * Tool API key configurations. + * This is the source of truth for tool credentials - used by both the config wizard + * and session startup to load keys from auth.json into environment variables. + */ +export const TOOL_KEYS = [ { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" }, { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" }, { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" }, @@ -635,7 +669,28 @@ const TOOL_KEYS = [ { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" }, ] as const; -function getConfigAuthStorage(): InstanceType { +/** + * Load tool API keys from auth.json into environment variables. + * Called at session startup to ensure tools have access to their credentials. + */ +export function loadToolApiKeys(): void { + try { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + if (!existsSync(authPath)) return; + + const auth = AuthStorage.create(authPath); + for (const tool of TOOL_KEYS) { + const cred = auth.get(tool.id); + if (cred && cred.type === "api_key" && cred.key && !process.env[tool.env]) { + process.env[tool.env] = cred.key; + } + } + } catch { + // Failed to load tool keys — ignore, they can still be set via env vars + } +} + +function getConfigAuthStorage(): AuthStorage { const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); mkdirSync(dirname(authPath), { recursive: true }); return AuthStorage.create(authPath); @@ -662,7 +717,7 @@ async function handleConfig(ctx: ExtensionCommandContext): Promise { let changed = false; while (true) { const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options); - if (!choice || choice === "(done)") break; + if (!choice || typeof choice !== "string" || choice === "(done)") break; const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label)); if (toolIdx === -1) break; @@ -841,12 +896,9 @@ async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Pro // ─── Branch cleanup handler ────────────────────────────────────────────────── async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise { - const { execFileSync } = await import("node:child_process"); - let branches: string[]; try { - const output = execFileSync("git", ["branch", "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" }); - branches = output.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean); + branches = nativeBranchList(basePath, "gsd/*"); } catch { ctx.ui.notify("No GSD branches found.", "info"); return; @@ -857,18 +909,11 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str return; } - let mainBranch: string; - try { - mainBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: basePath, timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }) - .trim().replace("origin/", ""); - } catch { - mainBranch = "main"; - } + const mainBranch = nativeDetectMainBranch(basePath); let merged: string[]; try { - const output = execFileSync("git", ["branch", "--merged", mainBranch, "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" }); - merged = output.split("\n").map(b => b.trim()).filter(Boolean); + merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*"); } catch { merged = []; } @@ -881,7 +926,7 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str let deleted = 0; for (const branch of merged) { try { - execFileSync("git", ["branch", "-d", branch], { cwd: basePath, timeout: 5000, stdio: "ignore" }); + nativeBranchDelete(basePath, branch, false); deleted++; } catch { /* skip branches that can't be deleted */ } } @@ -892,12 +937,9 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str // ─── Snapshot cleanup handler ───────────────────────────────────────────────── async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise { - const { execFileSync } = await import("node:child_process"); - let refs: string[]; try { - const output = execFileSync("git", ["for-each-ref", "refs/gsd/snapshots/", "--format=%(refname)"], { cwd: basePath, timeout: 10000, encoding: "utf-8" }); - refs = output.split("\n").filter(Boolean); + refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); } catch { ctx.ui.notify("No snapshot refs found.", "info"); return; @@ -921,7 +963,7 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st const sorted = labelRefs.sort(); for (const old of sorted.slice(0, -5)) { try { - execFileSync("git", ["update-ref", "-d", old], { cwd: basePath, timeout: 5000, stdio: "ignore" }); + nativeUpdateRef(basePath, old); pruned++; } catch { /* skip */ } } @@ -929,3 +971,46 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); } + +async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const basePath = process.cwd(); + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id ?? "none"; + const sid = state.activeSlice?.id ?? "none"; + const tid = state.activeTask?.id ?? "none"; + const appliedAt = `${mid}/${sid}/${tid}`; + await appendOverride(basePath, change, appliedAt); + + if (isAutoActive()) { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.", + "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", + "", + "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info"); + } else { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md`.", + "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.", + "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info"); + } +} diff --git a/src/resources/extensions/gsd/complexity.ts b/src/resources/extensions/gsd/complexity.ts new file mode 100644 index 000000000..7fac93a73 --- /dev/null +++ b/src/resources/extensions/gsd/complexity.ts @@ -0,0 +1,236 @@ +/** + * GSD Task Complexity Classification + * + * Classifies task plans and unit types by complexity to enable model routing. + * Pure heuristics + adaptive learning — no LLM calls, sub-millisecond. + * + * Combined approach: + * - Task plan analysis (step count, file count, description length, signal words) + * - Unit type defaults (complete-slice → light, replan → heavy, etc.) + * - Budget pressure thresholds (50/75/90% graduated downgrade) + * - Adaptive learning via routing-history (optional) + * + * Classification output uses our TokenProfile-aligned TaskComplexity type + * for the simple classifier, and ComplexityTier for the full unit classifier. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ComplexityTier, ClassificationResult, TaskMetadata } from "./types.js"; + +// Re-export for convenience +export type { ComplexityTier, ClassificationResult, TaskMetadata }; + +// ─── Simple Task Complexity (for task plan analysis) ────────────────────── + +export type TaskComplexity = "simple" | "standard" | "complex"; + +/** Words that signal non-trivial work requiring full reasoning capacity */ +const COMPLEXITY_SIGNALS = [ + "research", "investigate", "refactor", "migrate", "integrate", + "complex", "architect", "redesign", "security", "performance", + "concurrent", "parallel", "distributed", "backward.?compat", + "migration", "architecture", "concurrency", "compatibility", +]; +const COMPLEXITY_PATTERN = new RegExp(COMPLEXITY_SIGNALS.join("|"), "i"); + +/** + * Classify a task plan by its structural complexity. + * Used by dispatch to select execution_simple vs execution model. + */ +export function classifyTaskComplexity(planContent: string): TaskComplexity { + if (!planContent || planContent.trim().length === 0) return "standard"; + + const stepsMatch = planContent.match(/##\s*Steps\s*\n([\s\S]*?)(?=\n##|\n---|$)/i); + const stepsSection = stepsMatch?.[1] ?? ""; + const stepCount = (stepsSection.match(/^\s*\d+\.\s/gm) ?? []).length; + + if (!stepsMatch) return "standard"; + + const stepsIdx = planContent.search(/##\s*Steps/i); + const descriptionLength = stepsIdx > 0 ? planContent.slice(0, stepsIdx).length : planContent.length; + + const filePatterns = planContent.match(/`[a-zA-Z0-9_/.-]+\.[a-z]{1,4}`/g) ?? []; + const uniqueFiles = new Set(filePatterns.map(f => f.replace(/`/g, ""))); + const fileCount = uniqueFiles.size; + + const hasComplexitySignals = COMPLEXITY_PATTERN.test(planContent); + + // Count fenced code blocks (from #579 Phase 4) + const codeBlockCount = (planContent.match(/^```/gm) ?? []).length / 2; + + if (stepCount >= 8 || fileCount >= 8 || descriptionLength > 2000 || codeBlockCount >= 5) { + return "complex"; + } + + if (stepCount <= 3 && descriptionLength < 500 && fileCount <= 3 && !hasComplexitySignals) { + return "simple"; + } + + return "standard"; +} + +// ─── Unit Type → Default Tier Mapping (from #579) ───────────────────────── + +const UNIT_TYPE_TIERS: Record = { + // Light: structured summaries, completion, UAT + "complete-slice": "light", + "run-uat": "light", + + // Standard: research, routine planning + "research-milestone": "standard", + "research-slice": "standard", + "plan-milestone": "standard", + "plan-slice": "standard", + + // Heavy: execution default (upgraded by metadata), replanning + "execute-task": "standard", + "replan-slice": "heavy", + "reassess-roadmap": "heavy", + "complete-milestone": "standard", +}; + +/** + * Classify unit complexity for model routing. + * Uses unit type defaults, task metadata analysis, and budget pressure. + * + * @param unitType The type of unit being dispatched + * @param unitId The unit ID (e.g. "M001/S01/T01") + * @param basePath Project base path (for reading task plans) + * @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined + * @param metadata Optional pre-parsed task metadata + */ +export function classifyUnitComplexity( + unitType: string, + unitId: string, + basePath: string, + budgetPct?: number, + metadata?: TaskMetadata, +): ClassificationResult { + // Hook units default to light + if (unitType.startsWith("hook/")) { + return applyBudgetPressure({ tier: "light", reason: "hook unit", downgraded: false }, budgetPct); + } + + // Triage/capture units default to light + if (unitType === "triage-captures" || unitType.startsWith("quick-task")) { + return applyBudgetPressure({ tier: "light", reason: `${unitType} unit`, downgraded: false }, budgetPct); + } + + let tier = UNIT_TYPE_TIERS[unitType] ?? "standard"; + let reason = `unit type: ${unitType}`; + + // For execute-task, analyze task metadata for complexity signals + if (unitType === "execute-task") { + const analysis = analyzeTaskFromPlan(unitId, basePath, metadata); + if (analysis) { + tier = analysis.tier; + reason = analysis.reason; + } + } + + return applyBudgetPressure({ tier, reason, downgraded: false }, budgetPct); +} + +// ─── Tier Helpers ───────────────────────────────────────────────────────── + +export function tierLabel(tier: ComplexityTier): string { + switch (tier) { + case "light": return "L"; + case "standard": return "S"; + case "heavy": return "H"; + } +} + +export function tierOrdinal(tier: ComplexityTier): number { + switch (tier) { + case "light": return 0; + case "standard": return 1; + case "heavy": return 2; + } +} + +export function escalateTier(currentTier: ComplexityTier): ComplexityTier | null { + switch (currentTier) { + case "light": return "standard"; + case "standard": return "heavy"; + case "heavy": return null; + } +} + +// ─── Budget Pressure (from #579 — graduated thresholds) ─────────────────── + +function applyBudgetPressure( + result: ClassificationResult, + budgetPct?: number, +): ClassificationResult { + if (budgetPct === undefined || budgetPct < 0.5) return result; + + const original = result.tier; + + if (budgetPct >= 0.9) { + // >90%: almost everything goes to light + if (result.tier !== "heavy") { + result.tier = "light"; + } else { + result.tier = "standard"; + } + } else if (budgetPct >= 0.75) { + // 75-90%: only heavy stays, standard → light + if (result.tier === "standard") { + result.tier = "light"; + } + } else { + // 50-75%: standard → light + if (result.tier === "standard") { + result.tier = "light"; + } + } + + if (result.tier !== original) { + result.downgraded = true; + result.reason = `${result.reason} (budget pressure: ${Math.round(budgetPct * 100)}%)`; + } + + return result; +} + +// ─── Task Plan Analysis ─────────────────────────────────────────────────── + +interface TaskAnalysis { + tier: ComplexityTier; + reason: string; +} + +function analyzeTaskFromPlan( + unitId: string, + basePath: string, + metadata?: TaskMetadata, +): TaskAnalysis | null { + // Try to read the task plan for analysis + const parts = unitId.split("/"); + if (parts.length < 3) return null; + + const [mid, sid, tid] = parts; + const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-PLAN.md`); + + let planContent = ""; + try { + if (existsSync(planPath)) { + planContent = readFileSync(planPath, "utf-8"); + } + } catch { + return null; + } + + if (!planContent) return null; + + const taskComplexity = classifyTaskComplexity(planContent); + + // Map TaskComplexity to ComplexityTier + switch (taskComplexity) { + case "simple": return { tier: "light", reason: "task plan: simple (few steps, small scope)" }; + case "complex": return { tier: "heavy", reason: "task plan: complex (many steps/files or signal words)" }; + default: return { tier: "standard", reason: "task plan: standard complexity" }; + } +} diff --git a/src/resources/extensions/gsd/crash-recovery.ts b/src/resources/extensions/gsd/crash-recovery.ts index bb9bd6d6c..d58f903e4 100644 --- a/src/resources/extensions/gsd/crash-recovery.ts +++ b/src/resources/extensions/gsd/crash-recovery.ts @@ -50,7 +50,7 @@ export function writeLock( sessionFile, }; writeFileSync(lockPath(basePath), JSON.stringify(data, null, 2), "utf-8"); - } catch { /* non-fatal */ } + } catch (e) { /* non-fatal: lock write failure */ void e; } } /** Remove the lock file on clean stop. */ @@ -58,7 +58,7 @@ export function clearLock(basePath: string): void { try { const p = lockPath(basePath); if (existsSync(p)) unlinkSync(p); - } catch { /* non-fatal */ } + } catch (e) { /* non-fatal: lock clear failure */ void e; } } /** Check if a crash lock exists and return its data. */ @@ -68,7 +68,8 @@ export function readCrashLock(basePath: string): LockData | null { if (!existsSync(p)) return null; const raw = readFileSync(p, "utf-8"); return JSON.parse(raw) as LockData; - } catch { + } catch (e) { + /* non-fatal: corrupt or unreadable lock file */ void e; return null; } } diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index d3e081ca0..410f3db96 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -89,6 +89,7 @@ export class GSDDashboardOverlay { private loading = true; private loadedDashboardIdentity?: string; private refreshInFlight: Promise | null = null; + private disposed = false; constructor( tui: { requestRender: () => void }, @@ -108,7 +109,7 @@ export class GSDDashboardOverlay { } private scheduleRefresh(initial = false): void { - if (this.refreshInFlight) return; + if (this.refreshInFlight || this.disposed) return; this.refreshInFlight = this.refreshDashboard(initial) .finally(() => { this.refreshInFlight = null; @@ -136,11 +137,13 @@ export class GSDDashboardOverlay { } private async refreshDashboard(initial = false): Promise { + if (this.disposed) return; this.dashData = getAutoDashboardData(); const nextIdentity = this.computeDashboardIdentity(this.dashData); if (initial || nextIdentity !== this.loadedDashboardIdentity) { const loaded = await this.loadData(); + if (this.disposed) return; if (loaded) { this.loadedDashboardIdentity = nextIdentity; } @@ -529,6 +532,7 @@ export class GSDDashboardOverlay { } dispose(): void { + this.disposed = true; clearInterval(this.refreshTimer); } } diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 2509a7c9b..01b729987 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -1,6 +1,9 @@ -import { execSync } from "node:child_process"; +// GSD Dispatch Guard — prevents out-of-order slice dispatch +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync } from "node:fs"; import { readdirSync } from "node:fs"; -import { relMilestoneFile, milestonesDir } from "./paths.js"; +import { resolveMilestoneFile, milestonesDir } from "./paths.js"; import { parseRoadmapSlices } from "./roadmap-slices.js"; import { extractMilestoneSeq, milestoneIdSort } from "./guided-flow.js"; @@ -12,19 +15,29 @@ const SLICE_DISPATCH_TYPES = new Set([ "complete-slice", ]); -function readTrackedFileFromBranch(base: string, branch: string, relPath: string): string | null { +/** + * Read a roadmap file from disk (working tree) rather than from a git branch. + * + * Prior implementation used `git show :` which read committed + * state on a specific branch. This caused false-positive blockers when work + * was committed on a milestone/worktree branch but the integration branch + * (main) hadn't been updated yet — the guard would see prior slices as + * incomplete on main even though they were done in the working tree (#530). + * + * Reading from disk always reflects the latest state, regardless of which + * branch is checked out or whether changes have been committed. + */ +function readRoadmapFromDisk(base: string, milestoneId: string): string | null { try { - return execSync(`git show ${branch}:${relPath}`, { - cwd: base, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); + const absPath = resolveMilestoneFile(base, milestoneId, "ROADMAP"); + if (!absPath) return null; + return readFileSync(absPath, "utf-8").trim(); } catch { return null; } } -export function getPriorSliceCompletionBlocker(base: string, mainBranch: string, unitType: string, unitId: string): string | null { +export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null { if (!SLICE_DISPATCH_TYPES.has(unitType)) return null; const [targetMid, targetSid] = unitId.split("/"); @@ -50,17 +63,15 @@ export function getPriorSliceCompletionBlocker(base: string, mainBranch: string, } for (const mid of milestoneIds) { - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapRel) continue; - - const roadmapContent = readTrackedFileFromBranch(base, mainBranch, roadmapRel); + // Read from disk (working tree) — always has the latest state + const roadmapContent = readRoadmapFromDisk(base, mid); if (!roadmapContent) continue; const slices = parseRoadmapSlices(roadmapContent); if (mid !== targetMid) { const incomplete = slices.find(slice => !slice.done); if (incomplete) { - return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete on ${mainBranch}.`; + return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete.`; } continue; } @@ -70,7 +81,7 @@ export function getPriorSliceCompletionBlocker(base: string, mainBranch: string, const incomplete = slices.slice(0, targetIndex).find(slice => !slice.done); if (incomplete) { - return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete on ${mainBranch}.`; + return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; } } diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index da2711435..03359444a 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -102,12 +102,13 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `git`: configures GSD's git behavior. All fields are optional — omit any to use defaults. Keys: - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`. - - `push_branches`: boolean — push newly created slice branches to the remote. Default: `false`. + - `push_branches`: boolean — push the milestone branch to the remote after commits. Default: `false`. - `remote`: string — git remote name to push to. Default: `"origin"`. - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`. - - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`. + - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a worktree back to the integration branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`. - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content. - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`. + - `commit_docs`: boolean — when `false`, prevents GSD from committing `.gsd/` planning artifacts to git. The `.gsd/` folder is added to `.gitignore` and kept local-only. Useful for teams where only some members use GSD, or when company policy requires a clean repository. Default: `true`. - `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`. diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index ae03220c7..189af7b4e 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -1,4 +1,3 @@ -import { execSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import { join, sep } from "node:path"; @@ -9,6 +8,7 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences. import { listWorktrees } from "./worktree-manager.js"; import { abortAndReset } from "./git-self-heal.js"; import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js"; +import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js"; export type DoctorSeverity = "info" | "warning" | "error"; export type DoctorIssueCode = @@ -467,9 +467,7 @@ async function checkGitHealth( shouldFix: (code: DoctorIssueCode) => boolean, ): Promise { // Degrade gracefully if not a git repo - try { - execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" }); - } catch { + if (!nativeIsRepo(basePath)) { return; // Not a git repo — skip all git health checks } @@ -516,7 +514,7 @@ async function checkGitHealth( fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`); } else { try { - execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" }); + nativeWorktreeRemove(basePath, wt.path, true); fixesApplied.push(`removed orphaned worktree ${wt.path}`); } catch { fixesApplied.push(`failed to remove worktree ${wt.path}`); @@ -528,11 +526,8 @@ async function checkGitHealth( // ── Stale milestone branches ───────────────────────────────────────── try { - // Use unquoted glob — single quotes are not interpreted by cmd.exe on Windows, - // causing the pattern to match literally instead of as a glob. - const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim(); - if (branchOutput) { - const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean); + const branches = nativeBranchList(basePath, "milestone/*"); + if (branches.length > 0) { const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch)); for (const branch of branches) { @@ -557,7 +552,7 @@ async function checkGitHealth( if (shouldFix("stale_milestone_branch")) { try { - execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" }); + nativeBranchDelete(basePath, branch, true); fixesApplied.push(`deleted stale branch ${branch}`); } catch { fixesApplied.push(`failed to delete branch ${branch}`); @@ -610,9 +605,9 @@ async function checkGitHealth( const trackedPaths: string[] = []; for (const exclusion of RUNTIME_EXCLUSION_PATHS) { try { - const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim(); - if (output) { - trackedPaths.push(...output.split("\n").filter(Boolean)); + const files = nativeLsFiles(basePath, exclusion); + if (files.length > 0) { + trackedPaths.push(...files); } } catch { // Individual ls-files can fail — continue @@ -632,7 +627,7 @@ async function checkGitHealth( if (shouldFix("tracked_runtime_files")) { try { for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" }); + nativeRmCached(basePath, [exclusion]); } fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`); } catch { @@ -646,13 +641,8 @@ async function checkGitHealth( // ── Legacy slice branches ────────────────────────────────────────────── try { - const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', { - cwd: basePath, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - if (sliceBranches) { - const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean); + const branchList = nativeBranchList(basePath, "gsd/*/*"); + if (branchList.length > 0) { issues.push({ severity: "info", code: "legacy_slice_branches", diff --git a/src/resources/extensions/gsd/errors.ts b/src/resources/extensions/gsd/errors.ts new file mode 100644 index 000000000..ac30bb714 --- /dev/null +++ b/src/resources/extensions/gsd/errors.ts @@ -0,0 +1,31 @@ +/** + * GSD Error Types — Typed error hierarchy for diagnostics and crash recovery. + * + * All GSD-specific errors extend GSDError, which carries a stable `code` + * string suitable for programmatic matching. Error codes are defined as + * constants so callers can switch on them without string-matching. + */ + +// ─── Error Codes ────────────────────────────────────────────────────────────── + +export const GSD_STALE_STATE = "GSD_STALE_STATE"; +export const GSD_LOCK_HELD = "GSD_LOCK_HELD"; +export const GSD_DISPATCH_FAILED = "GSD_DISPATCH_FAILED"; +export const GSD_TIMEOUT = "GSD_TIMEOUT"; +export const GSD_ARTIFACT_MISSING = "GSD_ARTIFACT_MISSING"; +export const GSD_GIT_ERROR = "GSD_GIT_ERROR"; +export const GSD_MERGE_CONFLICT = "GSD_MERGE_CONFLICT"; +export const GSD_PARSE_ERROR = "GSD_PARSE_ERROR"; +export const GSD_IO_ERROR = "GSD_IO_ERROR"; + +// ─── Base Error ─────────────────────────────────────────────────────────────── + +export class GSDError extends Error { + readonly code: string; + + constructor(code: string, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "GSDError"; + this.code = code; + } +} diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 7e4c135e1..60caf003b 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -5,7 +5,7 @@ import { promises as fs } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { resolveMilestoneFile, relMilestoneFile } from './paths.js'; +import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './paths.js'; import { milestoneIdSort, findMilestoneIds } from './guided-flow.js'; import type { @@ -20,18 +20,22 @@ import type { import { checkExistingEnvKeys } from '../get-secrets-from-user.js'; import { parseRoadmapSlices } from './roadmap-slices.js'; -import { nativeParseRoadmap, nativeExtractSection, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; +import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; // ─── Parse Cache ────────────────────────────────────────────────────────── const CACHE_MAX = 50; -/** Fast composite key: length + first/last 100 chars. Unique enough for distinct markdown files. */ +/** Fast composite key: length + first/mid/last 100 chars. The middle sample + * prevents collisions when only a few characters change in the interior of + * a file (e.g., a checkbox [ ] → [x] that doesn't alter length or endpoints). */ function cacheKey(content: string): string { const len = content.length; const head = content.slice(0, 100); + const midStart = Math.max(0, Math.floor(len / 2) - 50); + const mid = len > 200 ? content.slice(midStart, midStart + 100) : ''; const tail = len > 100 ? content.slice(-100) : ''; - return `${len}:${head}:${tail}`; + return `${len}:${head}:${mid}:${tail}`; } const _parseCache = new Map(); @@ -354,6 +358,28 @@ export function parsePlan(content: string): SlicePlan { } function _parsePlanImpl(content: string): SlicePlan { + // Try native parser first for better performance + const nativeResult = nativeParsePlanFile(content); + if (nativeResult) { + return { + id: nativeResult.id, + title: nativeResult.title, + goal: nativeResult.goal, + demo: nativeResult.demo, + mustHaves: nativeResult.mustHaves, + tasks: nativeResult.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + done: t.done, + estimate: t.estimate, + ...(t.files.length > 0 ? { files: t.files } : {}), + ...(t.verify ? { verify: t.verify } : {}), + })), + filesLikelyTouched: nativeResult.filesLikelyTouched, + }; + } + const lines = content.split('\n'); const h1 = lines.find(l => l.startsWith('# ')); @@ -436,6 +462,36 @@ export function parseSummary(content: string): Summary { } function _parseSummaryImpl(content: string): Summary { + // Try native parser first for better performance + const nativeResult = nativeParseSummaryFile(content); + if (nativeResult) { + const nfm = nativeResult.frontmatter; + return { + frontmatter: { + id: nfm.id, + parent: nfm.parent, + milestone: nfm.milestone, + provides: nfm.provides, + requires: nfm.requires, + affects: nfm.affects, + key_files: nfm.keyFiles, + key_decisions: nfm.keyDecisions, + patterns_established: nfm.patternsEstablished, + drill_down_paths: nfm.drillDownPaths, + observability_surfaces: nfm.observabilitySurfaces, + duration: nfm.duration, + verification_result: nfm.verificationResult, + completed_at: nfm.completedAt, + blocker_discovered: nfm.blockerDiscovered, + }, + title: nativeResult.title, + oneLiner: nativeResult.oneLiner, + whatHappened: nativeResult.whatHappened, + deviations: nativeResult.deviations, + filesModified: nativeResult.filesModified, + }; + } + const [fmLines, body] = splitFrontmatter(content); const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; @@ -855,3 +911,103 @@ export async function getManifestStatus( return result; } + +// ─── Overrides ────────────────────────────────────────────────────────────── + +export interface Override { + timestamp: string; + change: string; + scope: "active" | "resolved"; + appliedAt: string; +} + +export async function appendOverride(basePath: string, change: string, appliedAt: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const timestamp = new Date().toISOString(); + const entry = [ + `## Override: ${timestamp}`, + "", + `**Change:** ${change}`, + `**Scope:** active`, + `**Applied-at:** ${appliedAt}`, + "", + "---", + "", + ].join("\n"); + + const existing = await loadFile(overridesPath); + if (existing) { + await saveFile(overridesPath, existing.trimEnd() + "\n\n" + entry); + } else { + const header = [ + "# GSD Overrides", + "", + "User-issued overrides that supersede plan document content.", + "", + "---", + "", + ].join("\n"); + await saveFile(overridesPath, header + entry); + } +} + +export async function loadActiveOverrides(basePath: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const content = await loadFile(overridesPath); + if (!content) return []; + return parseOverrides(content).filter(o => o.scope === "active"); +} + +export function parseOverrides(content: string): Override[] { + const overrides: Override[] = []; + const blocks = content.split(/^## Override: /m).slice(1); + + for (const block of blocks) { + const lines = block.split("\n"); + const timestamp = lines[0]?.trim() ?? ""; + let change = ""; + let scope: "active" | "resolved" = "active"; + let appliedAt = ""; + + for (const line of lines) { + const changeMatch = line.match(/^\*\*Change:\*\*\s*(.+)$/); + if (changeMatch) change = changeMatch[1].trim(); + const scopeMatch = line.match(/^\*\*Scope:\*\*\s*(.+)$/); + if (scopeMatch) scope = scopeMatch[1].trim() as "active" | "resolved"; + const appliedMatch = line.match(/^\*\*Applied-at:\*\*\s*(.+)$/); + if (appliedMatch) appliedAt = appliedMatch[1].trim(); + } + + if (change) { + overrides.push({ timestamp, change, scope, appliedAt }); + } + } + + return overrides; +} + +export function formatOverridesSection(overrides: Override[]): string { + if (overrides.length === 0) return ""; + + const entries = overrides.map((o, i) => [ + `${i + 1}. **${o.change}**`, + ` _Issued: ${o.timestamp} during ${o.appliedAt}_`, + ].join("\n")).join("\n"); + + return [ + "## Active Overrides (supersede plan content)", + "", + "The following overrides were issued by the user and supersede any conflicting content in plan documents below. Follow these overrides even if they contradict the inlined task plan.", + "", + entries, + "", + ].join("\n"); +} + +export async function resolveAllOverrides(basePath: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const content = await loadFile(overridesPath); + if (!content) return; + const updated = content.replace(/\*\*Scope:\*\* active/g, "**Scope:** resolved"); + await saveFile(overridesPath, updated); +} diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts index 305d01034..efe8d894d 100644 --- a/src/resources/extensions/gsd/git-self-heal.ts +++ b/src/resources/extensions/gsd/git-self-heal.ts @@ -10,10 +10,10 @@ * user-friendly messages suggesting `/gsd doctor`. */ -import { execSync } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { MergeConflictError } from "./git-service.js"; +import { nativeMergeAbort, nativeRebaseAbort, nativeResetHard } from "./native-git-bridge.js"; // Re-export for consumers export { MergeConflictError }; @@ -41,7 +41,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult { // Abort in-progress merge if (existsSync(join(gitDir, "MERGE_HEAD"))) { try { - execSync("git merge --abort", { cwd, stdio: "pipe" }); + nativeMergeAbort(cwd); cleaned.push("aborted merge"); } catch { // merge --abort can fail if state is really broken; continue to reset @@ -63,7 +63,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult { // Abort in-progress rebase if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) { try { - execSync("git rebase --abort", { cwd, stdio: "pipe" }); + nativeRebaseAbort(cwd); cleaned.push("aborted rebase"); } catch { cleaned.push("rebase abort attempted (may have failed)"); @@ -72,7 +72,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult { // Always hard-reset to HEAD try { - execSync("git reset --hard HEAD", { cwd, stdio: "pipe" }); + nativeResetHard(cwd); if (cleaned.length > 0) { cleaned.push("reset to HEAD"); } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 966ef6d3e..9e2fb7fbb 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -8,9 +8,9 @@ * paths, commit type inference, and the runGit shell helper. */ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join, sep } from "node:path"; +import { join } from "node:path"; import { detectWorktreeName, @@ -21,7 +21,15 @@ import { nativeDetectMainBranch, nativeBranchExists, nativeHasChanges, + nativeAddAll, + nativeResetPaths, + nativeHasStagedChanges, + nativeCommit, + nativeRmCached, + nativeUpdateRef, + nativeAddPaths, } from "./native-git-bridge.js"; +import { GSDError, GSD_MERGE_CONFLICT } from "./errors.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -34,6 +42,16 @@ export interface GitPreferences { commit_type?: string; main_branch?: string; merge_strategy?: "squash" | "merge"; + /** Controls auto-mode git isolation strategy. + * - "worktree": (default) creates a milestone worktree for isolated work + * - "branch": works directly in the project root (for submodule-heavy repos) + */ + isolation?: "worktree" | "branch"; + /** When false, prevents GSD from committing .gsd/ planning artifacts to git. + * The .gsd/ folder is added to .gitignore and kept local-only. + * Default: true (planning docs are tracked in git). + */ + commit_docs?: boolean; } export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; @@ -48,7 +66,7 @@ export interface CommitOptions { * The working tree is left in a conflicted state (no reset) so the * caller can dispatch a fix-merge session to resolve it. */ -export class MergeConflictError extends Error { +export class MergeConflictError extends GSDError { readonly conflictedFiles: string[]; readonly strategy: "squash" | "merge"; readonly branch: string; @@ -61,6 +79,7 @@ export class MergeConflictError extends Error { mainBranch: string, ) { super( + GSD_MERGE_CONFLICT, `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" ` + `failed with conflicts in ${conflictedFiles.length} non-.gsd file(s): ${conflictedFiles.join(", ")}`, ); @@ -138,7 +157,7 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st * * The file is committed immediately so the metadata is persisted in git. */ -export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void { +export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string, options?: { commitDocs?: boolean }): void { // Don't record slice branches as the integration target if (SLICE_BRANCH_RE.test(branch)) return; // Validate @@ -164,14 +183,15 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8"); // Commit immediately so the metadata is persisted in git. - try { - runGit(basePath, ["add", metaFile]); - runGit(basePath, ["commit", "--no-verify", "-F", "-"], { - input: `chore(${milestoneId}): record integration branch`, - }); - } catch { - // Non-fatal — file is on disk even if commit fails (e.g. nothing to commit - // because the file was already tracked with identical content) + // Skip when commit_docs is explicitly false — .gsd/ is local-only. + if (options?.commitDocs !== false) { + try { + nativeAddPaths(basePath, [metaFile]); + nativeCommit(basePath, `chore(${milestoneId}): record integration branch`, { allowEmpty: false }); + } catch { + // Non-fatal — file is on disk even if commit fails (e.g. nothing to commit + // because the file was already tracked with identical content) + } } } @@ -205,7 +225,7 @@ function filterGitSvnNoise(message: string): string { */ export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string { try { - return execSync(`git ${args.join(" ")}`, { + return execFileSync("git", args, { cwd: basePath, stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"], encoding: "utf-8", @@ -272,7 +292,10 @@ export class GitServiceImpl { * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS. */ private smartStage(extraExclusions: readonly string[] = []): void { - const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; + // When commit_docs is false, exclude the entire .gsd/ directory from staging + const commitDocsDisabled = this.prefs.commit_docs === false; + const gsdExclusion = commitDocsDisabled ? [".gsd/"] : []; + const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...gsdExclusion, ...extraExclusions]; // One-time cleanup: if runtime files are already tracked in the index // (from older versions where the fallback bug staged them), untrack them @@ -281,11 +304,11 @@ export class GitServiceImpl { if (!this._runtimeFilesCleanedUp) { let cleaned = false; for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - const result = this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true }); - if (result && result.includes("rm '")) cleaned = true; + const removed = nativeRmCached(this.basePath, [exclusion]); + if (removed.length > 0) cleaned = true; } if (cleaned) { - this.git(["commit", "--no-verify", "-F", "-"], { input: "chore: untrack .gsd/ runtime files from git index" }); + nativeCommit(this.basePath, "chore: untrack .gsd/ runtime files from git index", { allowEmpty: false }); } this._runtimeFilesCleanedUp = true; } @@ -300,10 +323,10 @@ export class GitServiceImpl { // // git reset HEAD silently succeeds when the path isn't staged, so no // error handling is needed per-path. - this.git(["add", "-A"]); + nativeAddAll(this.basePath); for (const exclusion of allExclusions) { - this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true }); + try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ } } } @@ -319,13 +342,9 @@ export class GitServiceImpl { this.smartStage(); // Check if anything was actually staged - const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true }); - if (!staged && !opts.allowEmpty) return null; + if (!nativeHasStagedChanges(this.basePath) && !opts.allowEmpty) return null; - this.git( - ["commit", "--no-verify", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])], - { input: opts.message }, - ); + nativeCommit(this.basePath, opts.message, { allowEmpty: opts.allowEmpty ?? false }); return opts.message; } @@ -343,11 +362,10 @@ export class GitServiceImpl { // After smart staging, check if anything was actually staged // (all changes might have been runtime files that got excluded) - const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true }); - if (!staged) return null; + if (!nativeHasStagedChanges(this.basePath)) return null; const message = `chore(${unitId}): auto-commit after ${unitType}`; - this.git(["commit", "--no-verify", "-F", "-"], { input: message }); + nativeCommit(this.basePath, message, { allowEmpty: false }); return message; } @@ -424,7 +442,7 @@ export class GitServiceImpl { + String(now.getSeconds()).padStart(2, "0"); const refPath = `refs/gsd/snapshots/${label}/${ts}`; - this.git(["update-ref", refPath, "HEAD"]); + nativeUpdateRef(this.basePath, refPath, "HEAD"); } /** @@ -445,7 +463,7 @@ export class GitServiceImpl { } else { // Auto-detect: look for package.json with a test script try { - const pkg = execSync("cat package.json", { cwd: this.basePath, encoding: "utf-8" }); + const pkg = readFileSync(join(this.basePath, "package.json"), "utf-8"); const parsed = JSON.parse(pkg); if (parsed.scripts?.test) { command = "npm test"; diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index afde88d66..4b16b44e6 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { nativeRmCached } from "./native-git-bridge.js"; /** * Patterns that are always correct regardless of project type. @@ -78,15 +78,26 @@ const BASELINE_PATTERNS = [ * 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. + * + * When `commitDocs` is false, the entire `.gsd/` directory is added to + * .gitignore instead of individual runtime patterns, keeping all GSD + * artifacts local-only. */ -export function ensureGitignore(basePath: string): boolean { +export function ensureGitignore(basePath: string, options?: { commitDocs?: boolean }): boolean { const gitignorePath = join(basePath, ".gitignore"); + const commitDocs = options?.commitDocs !== false; // default true let existing = ""; if (existsSync(gitignorePath)) { existing = readFileSync(gitignorePath, "utf-8"); } + // When commit_docs is false, ensure blanket ".gsd/" is in .gitignore + // and skip the self-heal that would remove it. + if (!commitDocs) { + return ensureBlanketGsdIgnore(gitignorePath, existing); + } + // Self-heal: remove blanket ".gsd/" lines from pre-v2.14.0 projects. // The blanket ignore prevented planning artifacts (.gsd/milestones/) from // being tracked in git, causing artifacts to vanish in worktrees and @@ -152,10 +163,7 @@ export function untrackRuntimeFiles(basePath: string): void { // Use -r for directory patterns (trailing slash), strip the slash for the command const target = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; try { - execSync(`git rm -r --cached ${target}`, { - cwd: basePath, - stdio: ["ignore", "ignore", "ignore"], - }); + nativeRmCached(basePath, [target]); } catch { // File not tracked or doesn't exist — expected, ignore } @@ -206,7 +214,7 @@ See \`~/.gsd/agent/extensions/gsd/docs/preferences-reference.md\` for full field - \`models\`: Model preferences for specific task types - \`skill_discovery\`: Automatic skill detection preferences - \`auto_supervisor\`: Supervision and gating rules for autonomous modes -- \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, etc. +- \`git\`: Git preferences — \`main_branch\` (default branch name for new repos, e.g., "main", "master", "trunk"), \`auto_push\`, \`snapshots\`, \`commit_docs\` (set to \`false\` to keep .gsd/ local-only), etc. ## Examples @@ -227,3 +235,31 @@ custom_instructions: return true; } +/** + * When commit_docs is false, ensure `.gsd/` is in .gitignore as a blanket + * pattern. This keeps all GSD artifacts local-only. + * Returns true if the file was modified, false if already complete. + */ +function ensureBlanketGsdIgnore(gitignorePath: string, existing: string): boolean { + const existingLines = new Set( + existing + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")), + ); + + // Already has blanket .gsd/ ignore + if (existingLines.has(".gsd/") || existingLines.has(".gsd")) return false; + + const block = [ + "", + "# ── GSD (local-only, commit_docs: false) ──", + ".gsd/", + "", + ].join("\n"); + + 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 index 4d6dbd33c..58e91d351 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -23,7 +23,7 @@ import { import { randomInt } from "node:crypto"; import { join } from "node:path"; import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; -import { execSync, execFileSync } from "node:child_process"; +import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { showConfirm } from "../shared/confirm-ui.js"; @@ -50,9 +50,11 @@ export function checkAutoStartAfterDiscuss(): boolean { const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart; - // Gate 1: Primary milestone must have CONTEXT.md + // Gate 1: Primary milestone must have CONTEXT.md or ROADMAP.md + // The "discuss" path creates CONTEXT.md; the "plan" path creates ROADMAP.md. const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT"); - if (!contextFile) return false; // no context yet — keep waiting + const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting // Gate 2: STATE.md must exist — written as the last step in the discuss // output phase. This prevents auto-start from firing during Phase 3 @@ -131,7 +133,10 @@ export function checkAutoStartAfterDiscuss(): boolean { try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ } pendingAutoStart = null; - startAuto(ctx, pi, basePath, false, { step }).catch(() => {}); + startAuto(ctx, pi, basePath, false, { step }).catch((err) => { + ctx.ui.notify(`Auto-start failed: ${err instanceof Error ? err.message : String(err)}`, "warning"); + if (process.env.GSD_DEBUG) console.error('[gsd] auto start error:', err); + }); return true; } @@ -701,15 +706,14 @@ export async function showSmartEntry( const stepMode = options?.step; // ── Ensure git repo exists — GSD needs it for worktree isolation ────── - try { - execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" }); - } catch { + if (!nativeIsRepo(basePath)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; - execFileSync("git", ["init", "-b", mainBranch], { cwd: basePath, stdio: "pipe" }); + nativeInit(basePath, mainBranch); } // ── Ensure .gitignore has baseline patterns ────────────────────────── - ensureGitignore(basePath); + const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs; + ensureGitignore(basePath, { commitDocs }); untrackRuntimeFiles(basePath); // ── No GSD project OR no milestone → Create first/next milestone ──── @@ -720,13 +724,14 @@ export async function showSmartEntry( // ── Create PREFERENCES.md template ──────────────────────────────── ensurePreferences(basePath); - try { - execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", { - cwd: basePath, - stdio: "pipe", - }); - } catch { - // nothing to commit — that's fine + // Only commit .gsd/ init when commit_docs is not explicitly false + if (commitDocs !== false) { + try { + nativeAddPaths(basePath, [".gsd", ".gitignore"]); + nativeCommit(basePath, "chore: init gsd"); + } catch { + // nothing to commit — that's fine + } } } @@ -944,6 +949,7 @@ export async function showSmartEntry( }); if (choice === "plan") { + pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; const planMilestoneTemplates = [ inlineTemplate("roadmap", "Roadmap"), inlineTemplate("plan", "Slice Plan"), @@ -1112,7 +1118,7 @@ export async function showSmartEntry( inlineTemplate("uat", "UAT"), ].join("\n\n---\n\n"); dispatchWorkflow(pi, loadPrompt("guided-complete-slice", { - milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates, + workingDirectory: basePath, milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates, })); } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js"); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index d51b59125..a97e83a8a 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -25,10 +25,10 @@ import type { } from "@gsd/pi-coding-agent"; import { createBashTool, createWriteTool, createReadTool, createEditTool, isToolCallEventType } from "@gsd/pi-coding-agent"; -import { registerGSDCommand } from "./commands.js"; +import { registerGSDCommand, loadToolApiKeys } from "./commands.js"; import { registerExitCommand } from "./exit-command.js"; import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; -import { saveFile, formatContinue, loadFile, parseContinue, parseSummary } from "./files.js"; +import { saveFile, formatContinue, loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; import { deriveState } from "./state.js"; import { isAutoActive, isAutoPaused, handleAgentEnd, pauseAuto, getAutoDashboardData } from "./auto.js"; @@ -53,6 +53,7 @@ import { join } from "node:path"; import { existsSync } from "node:fs"; import { shortcutDesc } from "../shared/terminal.js"; import { Text } from "@gsd/pi-tui"; +import { pauseAutoForProviderError } from "./provider-error-pause.js"; // ── Depth verification state ────────────────────────────────────────────── let depthVerificationDone = false; @@ -187,7 +188,7 @@ export default function (pi: ExtensionAPI) { }; pi.registerTool(dynamicEdit as any); - // ── session_start: render branded GSD header + remote channel status ── + // ── session_start: render branded GSD header + load tool keys + remote status ── pi.on("session_start", async (_event, ctx) => { // Theme access throws in RPC mode (no TUI) — header is decorative, skip it try { @@ -203,6 +204,9 @@ export default function (pi: ExtensionAPI) { // RPC mode — no TUI, skip header rendering } + // Load tool API keys from auth.json into environment + loadToolApiKeys(); + // Notify remote questions status if configured try { const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ @@ -385,8 +389,7 @@ export default function (pi: ExtensionAPI) { } } - (ctx as any).log(`Auto-mode paused due to provider error${errorDetail}`); - await pauseAuto(ctx, pi); + await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi)); return; } @@ -600,9 +603,13 @@ async function buildTaskExecutionContextInjection( const priorTaskLines = await buildCarryForwardLines(basePath, milestoneId, sliceId, taskId); const resumeSection = await buildResumeSection(basePath, milestoneId, sliceId); + const activeOverrides = await loadActiveOverrides(basePath); + const overridesSection = formatOverridesSection(activeOverrides); + 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.", + overridesSection, "", "", resumeSection, "", diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 767f15356..c1a465ba4 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -303,6 +303,50 @@ export function formatCost(cost: number): string { return `$${n.toFixed(2)}`; } +// ─── Budget Prediction ──────────────────────────────────────────────────────── + +/** + * Calculate average cost per unit type from completed units. + * Returns a Map from unit type to average cost in USD. + */ +export function getAverageCostPerUnitType(units: UnitMetrics[]): Map { + const sums = new Map(); + for (const u of units) { + const entry = sums.get(u.type) ?? { total: 0, count: 0 }; + entry.total += u.cost; + entry.count += 1; + sums.set(u.type, entry); + } + const avgs = new Map(); + for (const [type, { total, count }] of sums) { + avgs.set(type, total / count); + } + return avgs; +} + +/** + * Estimate remaining cost given average costs and remaining unit counts. + * @param avgCosts - Average cost per unit type + * @param remainingUnits - Array of unit types still to dispatch + * @param fallbackAvg - Fallback average if unit type not seen before + * @returns Estimated remaining cost in USD + */ +export function predictRemainingCost( + avgCosts: Map, + remainingUnits: string[], + fallbackAvg?: number, +): number { + // If no averages available, use overall average as fallback + const allAvgs = [...avgCosts.values()]; + const overallAvg = fallbackAvg ?? (allAvgs.length > 0 ? allAvgs.reduce((a, b) => a + b, 0) / allAvgs.length : 0); + + let total = 0; + for (const unitType of remainingUnits) { + total += avgCosts.get(unitType) ?? overallAvg; + } + return total; +} + /** * Compute a projected remaining cost based on completed slice averages. * diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index e613409a5..0b4fd1aa1 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -1,11 +1,13 @@ // Native Git Bridge -// Provides fast READ-ONLY git operations backed by libgit2 via the Rust native module. -// Falls back to execSync git commands when the native module is unavailable. +// Provides high-performance git operations backed by libgit2 via the Rust native module. +// Falls back to execSync/execFileSync git commands when the native module is unavailable. // -// Only READ operations are native — WRITE operations (commit, merge, checkout, push) -// remain as execSync calls in git-service.ts. +// Both READ and WRITE operations are native — push operations remain as +// execSync calls because git2 credential handling is too complex. -import { execSync } from "node:child_process"; +import { execSync, execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs"; +import { join } from "node:path"; /** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ const GIT_NO_PROMPT_ENV = { @@ -15,7 +17,58 @@ const GIT_NO_PROMPT_ENV = { GIT_SVN_ID: "", }; +// Issue #453: keep auto-mode bookkeeping on the stable git CLI path unless a +// caller explicitly opts into the native helper. +const NATIVE_GSD_GIT_ENABLED = process.env.GSD_ENABLE_NATIVE_GSD_GIT === "1"; + +// ─── Native Module Types ────────────────────────────────────────────────── + +interface GitDiffStat { + filesChanged: number; + insertions: number; + deletions: number; + summary: string; +} + +interface GitNameStatus { + status: string; + path: string; +} + +interface GitNumstat { + added: number; + removed: number; + path: string; +} + +interface GitLogEntry { + sha: string; + message: string; +} + +interface GitWorktreeEntry { + path: string; + branch: string; + isBare: boolean; +} + +interface GitBatchInfo { + branch: string; + hasChanges: boolean; + status: string; + stagedCount: number; + unstagedCount: number; +} + +interface GitMergeResult { + success: boolean; + conflicts: string[]; +} + +// ─── Native Module Loading ────────────────────────────────────────────────── + let nativeModule: { + // Existing read functions gitCurrentBranch: (repoPath: string) => string | null; gitMainBranch: (repoPath: string) => string; gitBranchExists: (repoPath: string, branch: string) => boolean; @@ -23,6 +76,43 @@ let nativeModule: { gitWorkingTreeStatus: (repoPath: string) => string; gitHasChanges: (repoPath: string) => boolean; gitCommitCountBetween: (repoPath: string, fromRef: string, toRef: string) => number; + // New read functions + gitIsRepo: (path: string) => boolean; + gitHasStagedChanges: (repoPath: string) => boolean; + gitDiffStat: (repoPath: string, fromRef: string, toRef: string) => GitDiffStat; + gitDiffNameStatus: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, useMergeBase?: boolean) => GitNameStatus[]; + gitDiffNumstat: (repoPath: string, fromRef: string, toRef: string) => GitNumstat[]; + gitDiffContent: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, exclude?: string, useMergeBase?: boolean) => string; + gitLogOneline: (repoPath: string, fromRef: string, toRef: string) => GitLogEntry[]; + gitWorktreeList: (repoPath: string) => GitWorktreeEntry[]; + gitBranchList: (repoPath: string, pattern?: string) => string[]; + gitBranchListMerged: (repoPath: string, target: string, pattern?: string) => string[]; + gitLsFiles: (repoPath: string, pathspec: string) => string[]; + gitForEachRef: (repoPath: string, prefix: string) => string[]; + gitConflictFiles: (repoPath: string) => string[]; + gitBatchInfo: (repoPath: string) => GitBatchInfo; + // Write functions + gitInit: (path: string, initialBranch?: string) => void; + gitAddAll: (repoPath: string) => void; + gitAddPaths: (repoPath: string, paths: string[]) => void; + gitResetPaths: (repoPath: string, paths: string[]) => void; + gitCommit: (repoPath: string, message: string, allowEmpty?: boolean) => string; + gitCheckoutBranch: (repoPath: string, branch: string) => void; + gitCheckoutTheirs: (repoPath: string, paths: string[]) => void; + gitMergeSquash: (repoPath: string, branch: string) => GitMergeResult; + gitMergeAbort: (repoPath: string) => void; + gitRebaseAbort: (repoPath: string) => void; + gitResetHard: (repoPath: string) => void; + gitBranchDelete: (repoPath: string, branch: string, force?: boolean) => void; + gitBranchForceReset: (repoPath: string, branch: string, target: string) => void; + gitRmCached: (repoPath: string, paths: string[], recursive?: boolean) => string[]; + gitRmForce: (repoPath: string, paths: string[]) => void; + gitWorktreeAdd: (repoPath: string, wtPath: string, branch: string, createBranch?: boolean, startPoint?: string) => void; + gitWorktreeRemove: (repoPath: string, wtPath: string, force?: boolean) => void; + gitWorktreePrune: (repoPath: string) => void; + gitRevertCommit: (repoPath: string, sha: string) => void; + gitRevertAbort: (repoPath: string) => void; + gitUpdateRef: (repoPath: string, refname: string, target?: string) => void; } | null = null; let loadAttempted = false; @@ -30,6 +120,7 @@ let loadAttempted = false; function loadNative(): typeof nativeModule { if (loadAttempted) return nativeModule; loadAttempted = true; + if (!NATIVE_GSD_GIT_ENABLED) return nativeModule; try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -44,10 +135,12 @@ function loadNative(): typeof nativeModule { return nativeModule; } -/** Run a git command via execSync. Returns trimmed stdout. */ +// ─── Fallback Helpers ────────────────────────────────────────────────────── + +/** Run a git command via execFileSync. Returns trimmed stdout. */ function gitExec(basePath: string, args: string[], allowFailure = false): string { try { - return execSync(`git ${args.join(" ")}`, { + return execFileSync("git", args, { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", @@ -59,6 +152,22 @@ function gitExec(basePath: string, args: string[], allowFailure = false): string } } +/** Run a git command via execFileSync. Returns trimmed stdout. */ +function gitFileExec(basePath: string, args: string[], allowFailure = false): string { + try { + return execFileSync("git", args, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + } catch { + if (allowFailure) return ""; + throw new Error(`git ${args.join(" ")} failed in ${basePath}`); + } +} + +// ─── Existing Read Functions ────────────────────────────────────────────── + /** * Get the current branch name. * Native: reads HEAD symbolic ref via libgit2. @@ -77,10 +186,6 @@ export function nativeGetCurrentBranch(basePath: string): string { * Detect the repo-level main branch (origin/HEAD → main → master → current). * Native: checks refs via libgit2. * Fallback: `git symbolic-ref` + `git show-ref` chain. - * - * Note: milestone integration branch and worktree detection are handled - * by the caller (GitServiceImpl.getMainBranch) — this only covers the - * repo-level default detection that spawned multiple git processes. */ export function nativeDetectMainBranch(basePath: string): string { const native = loadNative(); @@ -88,7 +193,6 @@ export function nativeDetectMainBranch(basePath: string): string { return native.gitMainBranch(basePath); } - // Fallback: same logic as GitServiceImpl.getMainBranch() repo-level detection const symbolic = gitExec(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], true); if (symbolic) { const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/); @@ -173,9 +277,741 @@ export function nativeCommitCountBetween(basePath: string, fromRef: string, toRe return parseInt(result, 10) || 0; } +// ─── New Read Functions ────────────────────────────────────────────────── + +/** + * Check if a path is inside a git repository. + * Native: Repository::open() check. + * Fallback: `git rev-parse --git-dir`. + */ +export function nativeIsRepo(basePath: string): boolean { + const native = loadNative(); + if (native) { + return native.gitIsRepo(basePath); + } + try { + execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +/** + * Check if there are staged changes (index differs from HEAD). + * Native: libgit2 tree-to-index diff. + * Fallback: `git diff --cached --stat`. + */ +export function nativeHasStagedChanges(basePath: string): boolean { + const native = loadNative(); + if (native) { + return native.gitHasStagedChanges(basePath); + } + const result = gitExec(basePath, ["diff", "--cached", "--stat"], true); + return result !== ""; +} + +/** + * Get diff statistics. + * Use fromRef="HEAD", toRef="WORKDIR" for working tree diff. + * Use fromRef="HEAD", toRef="INDEX" for staged diff. + * Native: libgit2 diff stats. + * Fallback: `git diff --stat`. + */ +export function nativeDiffStat(basePath: string, fromRef: string, toRef: string): GitDiffStat { + const native = loadNative(); + if (native) { + return native.gitDiffStat(basePath, fromRef, toRef); + } + + // Fallback + let args: string[]; + if (fromRef === "HEAD" && toRef === "WORKDIR") { + args = ["diff", "--stat", "HEAD"]; + } else if (fromRef === "HEAD" && toRef === "INDEX") { + args = ["diff", "--stat", "--cached", "HEAD"]; + } else { + args = ["diff", "--stat", fromRef, toRef]; + } + + const result = gitExec(basePath, args, true); + // Parse numeric stats from the summary line (e.g. "3 files changed, 10 insertions(+), 2 deletions(-)") + let filesChanged = 0, insertions = 0, deletions = 0; + const statsMatch = result.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); + if (statsMatch) { + filesChanged = parseInt(statsMatch[1] ?? "0", 10); + insertions = parseInt(statsMatch[2] ?? "0", 10); + deletions = parseInt(statsMatch[3] ?? "0", 10); + } + return { filesChanged, insertions, deletions, summary: result }; +} + +/** + * Get name-status diff between two refs with optional pathspec filter. + * useMergeBase: if true, uses three-dot semantics (main...branch). + * Native: libgit2 tree-to-tree diff. + * Fallback: `git diff --name-status`. + */ +export function nativeDiffNameStatus( + basePath: string, + fromRef: string, + toRef: string, + pathspec?: string, + useMergeBase?: boolean, +): GitNameStatus[] { + const native = loadNative(); + if (native) { + return native.gitDiffNameStatus(basePath, fromRef, toRef, pathspec, useMergeBase); + } + + // Fallback + const separator = useMergeBase ? "..." : " "; + const args = ["diff", "--name-status", `${fromRef}${separator}${toRef}`]; + if (pathspec) args.push("--", pathspec); + + const result = gitExec(basePath, args, true); + if (!result) return []; + + return result.split("\n").filter(Boolean).map(line => { + const [status, ...pathParts] = line.split("\t"); + return { status: status ?? "", path: pathParts.join("\t") }; + }); +} + +/** + * Get numstat diff between two refs. + * Native: libgit2 patch line stats. + * Fallback: `git diff --numstat`. + */ +export function nativeDiffNumstat(basePath: string, fromRef: string, toRef: string): GitNumstat[] { + const native = loadNative(); + if (native) { + return native.gitDiffNumstat(basePath, fromRef, toRef); + } + + const result = gitExec(basePath, ["diff", "--numstat", fromRef, toRef], true); + if (!result) return []; + + return result.split("\n").filter(Boolean).map(line => { + const [a, r, ...pathParts] = line.split("\t"); + return { + added: a === "-" ? 0 : parseInt(a ?? "0", 10), + removed: r === "-" ? 0 : parseInt(r ?? "0", 10), + path: pathParts.join("\t"), + }; + }); +} + +/** + * Get unified diff content between two refs. + * useMergeBase: if true, uses three-dot semantics. + * Native: libgit2 diff print. + * Fallback: `git diff`. + */ +export function nativeDiffContent( + basePath: string, + fromRef: string, + toRef: string, + pathspec?: string, + exclude?: string, + useMergeBase?: boolean, +): string { + const native = loadNative(); + if (native) { + return native.gitDiffContent(basePath, fromRef, toRef, pathspec, exclude, useMergeBase); + } + + const separator = useMergeBase ? "..." : " "; + const args = ["diff", `${fromRef}${separator}${toRef}`]; + if (pathspec) { + args.push("--", pathspec); + } else if (exclude) { + args.push("--", ".", `:(exclude)${exclude}`); + } + + return gitExec(basePath, args, true); +} + +/** + * Get commit log between two refs (from..to). + * Native: libgit2 revwalk. + * Fallback: `git log --oneline from..to`. + */ +export function nativeLogOneline(basePath: string, fromRef: string, toRef: string): GitLogEntry[] { + const native = loadNative(); + if (native) { + return native.gitLogOneline(basePath, fromRef, toRef); + } + + const result = gitExec(basePath, ["log", "--oneline", `${fromRef}..${toRef}`], true); + if (!result) return []; + + return result.split("\n").filter(Boolean).map(line => { + const sha = line.substring(0, 7); + const message = line.substring(8); + return { sha, message }; + }); +} + +/** + * List git worktrees. + * Native: libgit2 worktree API. + * Fallback: `git worktree list --porcelain`. + */ +export function nativeWorktreeList(basePath: string): GitWorktreeEntry[] { + const native = loadNative(); + if (native) { + return native.gitWorktreeList(basePath); + } + + const result = gitExec(basePath, ["worktree", "list", "--porcelain"], true); + if (!result) return []; + + const entries: GitWorktreeEntry[] = []; + const blocks = result.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean); + + for (const block of blocks) { + const lines = block.split("\n"); + const wtLine = lines.find(l => l.startsWith("worktree ")); + const branchLine = lines.find(l => l.startsWith("branch ")); + const isBare = lines.some(l => l === "bare"); + + if (wtLine) { + entries.push({ + path: wtLine.replace("worktree ", ""), + branch: branchLine ? branchLine.replace("branch refs/heads/", "") : "", + isBare, + }); + } + } + + return entries; +} + +/** + * List branches matching an optional pattern. + * Native: libgit2 branch iterator. + * Fallback: `git branch --list `. + */ +export function nativeBranchList(basePath: string, pattern?: string): string[] { + const native = loadNative(); + if (native) { + return native.gitBranchList(basePath, pattern); + } + + const args = ["branch", "--list"]; + if (pattern) args.push(pattern); + + const result = gitFileExec(basePath, args, true); + if (!result) return []; + + return result.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean); +} + +/** + * List branches merged into target. + * Native: libgit2 merge-base check. + * Fallback: `git branch --merged --list `. + */ +export function nativeBranchListMerged(basePath: string, target: string, pattern?: string): string[] { + const native = loadNative(); + if (native) { + return native.gitBranchListMerged(basePath, target, pattern); + } + + const args = ["branch", "--merged", target]; + if (pattern) args.push("--list", pattern); + + const result = gitFileExec(basePath, args, true); + if (!result) return []; + + return result.split("\n").map(b => b.trim()).filter(Boolean); +} + +/** + * List tracked files matching a pathspec. + * Native: libgit2 index iteration. + * Fallback: `git ls-files `. + */ +export function nativeLsFiles(basePath: string, pathspec: string): string[] { + const native = loadNative(); + if (native) { + return native.gitLsFiles(basePath, pathspec); + } + + const result = gitFileExec(basePath, ["ls-files", pathspec], true); + if (!result) return []; + return result.split("\n").filter(Boolean); +} + +/** + * List references matching a prefix. + * Native: libgit2 references_glob. + * Fallback: `git for-each-ref --format=%(refname)`. + */ +export function nativeForEachRef(basePath: string, prefix: string): string[] { + const native = loadNative(); + if (native) { + return native.gitForEachRef(basePath, prefix); + } + + const result = gitFileExec(basePath, ["for-each-ref", prefix, "--format=%(refname)"], true); + if (!result) return []; + return result.split("\n").filter(Boolean); +} + +/** + * Get list of files with unmerged (conflict) entries. + * Native: libgit2 index conflicts. + * Fallback: `git diff --name-only --diff-filter=U`. + */ +export function nativeConflictFiles(basePath: string): string[] { + const native = loadNative(); + if (native) { + return native.gitConflictFiles(basePath); + } + + const result = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true); + if (!result) return []; + return result.split("\n").filter(Boolean); +} + +/** + * Get batch info: branch + status + change counts in ONE call. + * Native: single libgit2 call replaces 3-4 sequential execSync calls. + * Fallback: multiple git commands. + */ +export function nativeBatchInfo(basePath: string): GitBatchInfo { + const native = loadNative(); + if (native) { + return native.gitBatchInfo(basePath); + } + + const branch = gitExec(basePath, ["branch", "--show-current"], true); + const status = gitExec(basePath, ["status", "--porcelain"], true); + const hasChanges = status !== ""; + + // Parse porcelain status to count staged vs unstaged changes + let stagedCount = 0; + let unstagedCount = 0; + if (status) { + for (const line of status.split("\n")) { + if (!line || line.length < 2) continue; + const x = line[0]; // index (staged) status + const y = line[1]; // worktree (unstaged) status + if (x !== " " && x !== "?") stagedCount++; + if (y !== " " && y !== "?") unstagedCount++; + if (x === "?" && y === "?") unstagedCount++; // untracked files + } + } + + return { + branch, + hasChanges, + status, + stagedCount, + unstagedCount, + }; +} + +// ─── Write Functions ────────────────────────────────────────────────────── + +/** + * Initialize a new git repository. + * Native: libgit2 Repository::init. + * Fallback: `git init -b `. + */ +export function nativeInit(basePath: string, initialBranch?: string): void { + const native = loadNative(); + if (native) { + native.gitInit(basePath, initialBranch); + return; + } + + const args = ["init"]; + if (initialBranch) args.push("-b", initialBranch); + gitFileExec(basePath, args); +} + +/** + * Stage all files (git add -A). + * Native: libgit2 index add_all + update_all. + * Fallback: `git add -A`. + */ +export function nativeAddAll(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitAddAll(basePath); + return; + } + gitFileExec(basePath, ["add", "-A"]); +} + +/** + * Stage specific files. + * Native: libgit2 index add. + * Fallback: `git add -- `. + */ +export function nativeAddPaths(basePath: string, paths: string[]): void { + const native = loadNative(); + if (native) { + native.gitAddPaths(basePath, paths); + return; + } + gitFileExec(basePath, ["add", "--", ...paths]); +} + +/** + * Unstage files (reset index entries to HEAD). + * Native: libgit2 reset_default. + * Fallback: `git reset HEAD -- `. + */ +export function nativeResetPaths(basePath: string, paths: string[]): void { + const native = loadNative(); + if (native) { + native.gitResetPaths(basePath, paths); + return; + } + for (const p of paths) { + gitExec(basePath, ["reset", "HEAD", "--", p], true); + } +} + +/** + * Create a commit from the current index. + * Returns the commit SHA on success, or null if nothing to commit. + * Native: libgit2 commit create. + * Fallback: `git commit --no-verify -F -`. + */ +export function nativeCommit( + basePath: string, + message: string, + options?: { allowEmpty?: boolean; input?: string }, +): string | null { + const native = loadNative(); + if (native) { + try { + return native.gitCommit(basePath, message, options?.allowEmpty); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("nothing to commit")) return null; + throw e; + } + } + + // Fallback: use git commit with stdin pipe for safe multi-line messages + try { + const result = execSync( + `git commit --no-verify -F -${options?.allowEmpty ? " --allow-empty" : ""}`, + { + cwd: basePath, + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + input: message, + }, + ).trim(); + return result; + } catch (err: unknown) { + const errObj = err as { stdout?: string; stderr?: string; message?: string }; + const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" "); + if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) { + return null; + } + throw err; + } +} + +/** + * Checkout a branch (switch HEAD and update working tree). + * Native: libgit2 checkout + set_head. + * Fallback: `git checkout `. + */ +export function nativeCheckoutBranch(basePath: string, branch: string): void { + const native = loadNative(); + if (native) { + native.gitCheckoutBranch(basePath, branch); + return; + } + execSync(`git checkout ${branch}`, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); +} + +/** + * Resolve index conflicts by accepting "theirs" version. + * Native: libgit2 index conflict resolution. + * Fallback: `git checkout --theirs -- `. + */ +export function nativeCheckoutTheirs(basePath: string, paths: string[]): void { + const native = loadNative(); + if (native) { + native.gitCheckoutTheirs(basePath, paths); + return; + } + for (const path of paths) { + gitFileExec(basePath, ["checkout", "--theirs", "--", path]); + } +} + +/** + * Squash-merge a branch (stages changes, does NOT commit). + * Native: libgit2 merge with squash semantics. + * Fallback: `git merge --squash `. + */ +export function nativeMergeSquash(basePath: string, branch: string): GitMergeResult { + const native = loadNative(); + if (native) { + return native.gitMergeSquash(basePath, branch); + } + + try { + execSync(`git merge --squash ${branch}`, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + return { success: true, conflicts: [] }; + } catch { + // Check for conflicts + const conflictOutput = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true); + const conflicts = conflictOutput ? conflictOutput.split("\n").filter(Boolean) : []; + return { success: conflicts.length === 0, conflicts }; + } +} + +/** + * Abort an in-progress merge. + * Native: libgit2 reset + cleanup. + * Fallback: `git merge --abort`. + */ +export function nativeMergeAbort(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitMergeAbort(basePath); + return; + } + gitExec(basePath, ["merge", "--abort"], true); +} + +/** + * Abort an in-progress rebase. + * Native: libgit2 reset + cleanup. + * Fallback: `git rebase --abort`. + */ +export function nativeRebaseAbort(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitRebaseAbort(basePath); + return; + } + gitExec(basePath, ["rebase", "--abort"], true); +} + +/** + * Hard reset to HEAD. + * Native: libgit2 reset(Hard). + * Fallback: `git reset --hard HEAD`. + */ +export function nativeResetHard(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitResetHard(basePath); + return; + } + execSync("git reset --hard HEAD", { cwd: basePath, stdio: "pipe" }); +} + +/** + * Delete a branch. + * Native: libgit2 branch delete. + * Fallback: `git branch -D/-d `. + */ +export function nativeBranchDelete(basePath: string, branch: string, force = true): void { + const native = loadNative(); + if (native) { + native.gitBranchDelete(basePath, branch, force); + return; + } + gitFileExec(basePath, ["branch", force ? "-D" : "-d", branch], true); +} + +/** + * Force-reset a branch to point at a target ref. + * Native: libgit2 branch create with force. + * Fallback: `git branch -f `. + */ +export function nativeBranchForceReset(basePath: string, branch: string, target: string): void { + const native = loadNative(); + if (native) { + native.gitBranchForceReset(basePath, branch, target); + return; + } + gitExec(basePath, ["branch", "-f", branch, target]); +} + +/** + * Remove files from the index (cache) without touching the working tree. + * Returns list of removed files. + * Native: libgit2 index remove. + * Fallback: `git rm --cached -r --ignore-unmatch `. + */ +export function nativeRmCached(basePath: string, paths: string[], recursive = true): string[] { + const native = loadNative(); + if (native) { + return native.gitRmCached(basePath, paths, recursive); + } + + const removed: string[] = []; + for (const path of paths) { + const result = gitExec( + basePath, + ["rm", "--cached", ...(recursive ? ["-r"] : []), "--ignore-unmatch", path], + true, + ); + if (result) removed.push(result); + } + return removed; +} + +/** + * Force-remove files from both index and working tree. + * Native: libgit2 index remove + fs delete. + * Fallback: `git rm --force -- `. + */ +export function nativeRmForce(basePath: string, paths: string[]): void { + const native = loadNative(); + if (native) { + native.gitRmForce(basePath, paths); + return; + } + for (const path of paths) { + gitFileExec(basePath, ["rm", "--force", "--", path], true); + } +} + +/** + * Add a new git worktree. + * Native: libgit2 worktree API. + * Fallback: `git worktree add`. + */ +export function nativeWorktreeAdd( + basePath: string, + wtPath: string, + branch: string, + createBranch?: boolean, + startPoint?: string, +): void { + const native = loadNative(); + if (native) { + native.gitWorktreeAdd(basePath, wtPath, branch, createBranch, startPoint); + return; + } + + if (createBranch) { + gitExec(basePath, ["worktree", "add", "-b", branch, wtPath, startPoint ?? "HEAD"]); + } else { + gitExec(basePath, ["worktree", "add", wtPath, branch]); + } +} + +/** + * Remove a git worktree. + * Native: libgit2 worktree prune + fs cleanup. + * Fallback: `git worktree remove [--force] `. + */ +export function nativeWorktreeRemove(basePath: string, wtPath: string, force = false): void { + const native = loadNative(); + if (native) { + native.gitWorktreeRemove(basePath, wtPath, force); + return; + } + + const args = ["worktree", "remove"]; + if (force) args.push("--force"); + args.push(wtPath); + gitExec(basePath, args, true); +} + +/** + * Prune stale worktree entries. + * Native: libgit2 worktree validation + prune. + * Fallback: `git worktree prune`. + */ +export function nativeWorktreePrune(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitWorktreePrune(basePath); + return; + } + gitExec(basePath, ["worktree", "prune"], true); +} + +/** + * Revert a commit without auto-committing. + * Native: libgit2 revert. + * Fallback: `git revert --no-commit `. + */ +export function nativeRevertCommit(basePath: string, sha: string): void { + const native = loadNative(); + if (native) { + native.gitRevertCommit(basePath, sha); + return; + } + gitFileExec(basePath, ["revert", "--no-commit", sha]); +} + +/** + * Abort an in-progress revert. + * Native: libgit2 reset + cleanup. + * Fallback: `git revert --abort`. + */ +export function nativeRevertAbort(basePath: string): void { + const native = loadNative(); + if (native) { + native.gitRevertAbort(basePath); + return; + } + gitFileExec(basePath, ["revert", "--abort"], true); +} + +/** + * Create or delete a ref. + * When target is provided, creates/updates the ref. When undefined, deletes it. + * Native: libgit2 reference create/delete. + * Fallback: `git update-ref`. + */ +export function nativeUpdateRef(basePath: string, refname: string, target?: string): void { + const native = loadNative(); + if (native) { + native.gitUpdateRef(basePath, refname, target); + return; + } + + if (target !== undefined) { + gitExec(basePath, ["update-ref", refname, target]); + } else { + gitExec(basePath, ["update-ref", "-d", refname], true); + } +} + /** * Check if the native git module is available. */ export function isNativeGitAvailable(): boolean { return loadNative() !== null; } + +// ─── Re-exports for type consumers ────────────────────────────────────── + +export type { + GitDiffStat, + GitNameStatus, + GitNumstat, + GitLogEntry, + GitWorktreeEntry, + GitBatchInfo, + GitMergeResult, +}; diff --git a/src/resources/extensions/gsd/native-parser-bridge.ts b/src/resources/extensions/gsd/native-parser-bridge.ts index d56f9a3aa..0f4b8b69c 100644 --- a/src/resources/extensions/gsd/native-parser-bridge.ts +++ b/src/resources/extensions/gsd/native-parser-bridge.ts @@ -6,11 +6,15 @@ import type { Roadmap, BoundaryMapEntry, RoadmapSliceEntry, RiskLevel } from './types.js'; +// Issue #453: auto-mode post-turn reconciliation must stay on the stable JS path +// unless the native parser is explicitly requested. +const NATIVE_GSD_PARSER_ENABLED = process.env.GSD_ENABLE_NATIVE_GSD_PARSER === "1"; + let nativeModule: { parseFrontmatter: (content: string) => { metadata: string; body: string }; extractSection: (content: string, heading: string, level?: number) => { content: string; found: boolean }; extractAllSections: (content: string, level?: number) => string; - batchParseGsdFiles: (directory: string) => { files: Array<{ path: string; metadata: string; body: string; sections: string }>; count: number }; + batchParseGsdFiles: (directory: string) => { files: Array<{ path: string; metadata: string; body: string; sections: string; rawContent: string }>; count: number }; parseRoadmapFile: (content: string) => { title: string; vision: string; @@ -18,6 +22,10 @@ let nativeModule: { slices: Array<{ id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string }>; boundaryMap: Array<{ fromSlice: string; toSlice: string; produces: string; consumes: string }>; }; + scanGsdTree: (directory: string) => Array<{ path: string; name: string; isDir: boolean }>; + parseJsonlTail: (filePath: string, maxBytes?: number, maxEntries?: number) => { entries: string; count: number; truncated: boolean }; + parsePlanFile: (content: string) => NativePlanResult; + parseSummaryFile: (content: string) => NativeSummaryResult; } | null = null; let loadAttempted = false; @@ -25,6 +33,7 @@ let loadAttempted = false; function loadNative(): typeof nativeModule { if (loadAttempted) return nativeModule; loadAttempted = true; + if (!NATIVE_GSD_PARSER_ENABLED) return nativeModule; try { // Dynamic import to avoid hard dependency - fails gracefully if native module not built @@ -108,6 +117,7 @@ export interface BatchParsedFile { metadata: Record; body: string; sections: Record; + rawContent: string; } /** @@ -124,6 +134,7 @@ export function nativeBatchParseGsdFiles(directory: string): BatchParsedFile[] | metadata: JSON.parse(f.metadata) as Record, body: f.body, sections: JSON.parse(f.sections) as Record, + rawContent: f.rawContent, })); } @@ -133,3 +144,124 @@ export function nativeBatchParseGsdFiles(directory: string): BatchParsedFile[] | export function isNativeParserAvailable(): boolean { return loadNative() !== null; } + +// ─── Tree Scanning ──────────────────────────────────────────────────────────── + +export interface GsdTreeEntry { + path: string; + name: string; + isDir: boolean; +} + +/** + * Native-backed directory tree scan of a .gsd/ directory. + * Returns a flat list of all entries, or null if native module unavailable. + */ +export function nativeScanGsdTree(directory: string): GsdTreeEntry[] | null { + const native = loadNative(); + if (!native) return null; + return native.scanGsdTree(directory); +} + +// ─── JSONL Parsing ──────────────────────────────────────────────────────────── + +export interface JsonlParseResult { + entries: unknown[]; + count: number; + truncated: boolean; +} + +/** + * Native-backed JSONL tail parser. Reads the last `maxBytes` of a JSONL file + * and parses up to `maxEntries` entries with constant memory usage. + * Returns null if native module unavailable. + */ +export function nativeParseJsonlTail(filePath: string, maxBytes?: number, maxEntries?: number): JsonlParseResult | null { + const native = loadNative(); + if (!native) return null; + const result = native.parseJsonlTail(filePath, maxBytes, maxEntries); + return { + entries: JSON.parse(result.entries), + count: result.count, + truncated: result.truncated, + }; +} + +// ─── Plan & Summary File Parsing ────────────────────────────────────────────── + +export interface NativeTaskEntry { + id: string; + title: string; + description: string; + done: boolean; + estimate: string; + files: string[]; + verify: string; +} + +export interface NativePlanResult { + id: string; + title: string; + goal: string; + demo: string; + mustHaves: string[]; + tasks: NativeTaskEntry[]; + filesLikelyTouched: string[]; +} + +/** + * Native-backed plan file parser. + * Returns structured plan data or null if native module unavailable. + */ +export function nativeParsePlanFile(content: string): NativePlanResult | null { + const native = loadNative(); + if (!native) return null; + return native.parsePlanFile(content) as NativePlanResult; +} + +export interface NativeSummaryRequires { + slice: string; + provides: string; +} + +export interface NativeSummaryFrontmatter { + id: string; + parent: string; + milestone: string; + provides: string[]; + requires: NativeSummaryRequires[]; + affects: string[]; + keyFiles: string[]; + keyDecisions: string[]; + patternsEstablished: string[]; + drillDownPaths: string[]; + observabilitySurfaces: string[]; + duration: string; + verificationResult: string; + completedAt: string; + blockerDiscovered: boolean; +} + +export interface NativeFileModified { + path: string; + description: string; +} + +export interface NativeSummaryResult { + frontmatter: NativeSummaryFrontmatter; + title: string; + oneLiner: string; + whatHappened: string; + deviations: string; + filesModified: NativeFileModified[]; +} + +/** + * Native-backed summary file parser. + * Returns structured summary data or null if native module unavailable. + */ +export function nativeParseSummaryFile(content: string): NativeSummaryResult | null { + const native = loadNative(); + if (!native) return null; + return native.parseSummaryFile(content) as NativeSummaryResult; +} diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index 601e7e1d9..c89ec5788 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -11,15 +11,86 @@ import { readdirSync, existsSync, Dirent } from "node:fs"; import { join } from "node:path"; +import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js"; // ─── Directory Listing Cache ────────────────────────────────────────────────── const dirEntryCache = new Map(); const dirListCache = new Map(); +// ─── Native Tree Cache ──────────────────────────────────────────────────────── +// When the native module is available, scan the entire .gsd/ tree in one call +// and serve directory listings from memory instead of individual readdirSync calls. + +let nativeTreeCache: Map | null = null; +let nativeTreeBase: string | null = null; + +function getNativeTree(gsdDir: string): Map | null { + if (nativeTreeCache && nativeTreeBase === gsdDir) return nativeTreeCache; + + const entries = nativeScanGsdTree(gsdDir); + if (!entries) return null; + + // Build a map of parent directory -> entries + const tree = new Map(); + for (const entry of entries) { + const parts = entry.path.split('/'); + const parentPath = parts.slice(0, -1).join('/'); + const parentKey = parentPath || '.'; + if (!tree.has(parentKey)) tree.set(parentKey, []); + tree.get(parentKey)!.push(entry); + } + + nativeTreeCache = tree; + nativeTreeBase = gsdDir; + return tree; +} + +/** + * Convert a native tree lookup into a relative key for the tree map. + * Returns the relative path from the gsdDir, or null if the path isn't under gsdDir. + */ +function nativeTreeKey(dirPath: string, gsdDir: string): string | null { + if (!dirPath.startsWith(gsdDir)) return null; + const rel = dirPath.slice(gsdDir.length).replace(/^\//, ''); + return rel || '.'; +} + function cachedReaddirWithTypes(dirPath: string): Dirent[] { const cached = dirEntryCache.get(dirPath); if (cached) return cached; + + // Try native tree cache for paths under .gsd/ + if (nativeTreeBase) { + const key = nativeTreeKey(dirPath, nativeTreeBase); + if (key && nativeTreeCache) { + const treeEntries = nativeTreeCache.get(key); + if (treeEntries) { + // Synthesize Dirent-like objects from native tree entries + const dirents = treeEntries.map(e => { + const d = Object.create(Dirent.prototype) as Dirent; + Object.assign(d, { + name: e.name, + parentPath: dirPath, + path: dirPath, + }); + // Override the type check methods + const isDir = e.isDir; + d.isDirectory = () => isDir; + d.isFile = () => !isDir; + d.isSymbolicLink = () => false; + d.isBlockDevice = () => false; + d.isCharacterDevice = () => false; + d.isFIFO = () => false; + d.isSocket = () => false; + return d; + }); + dirEntryCache.set(dirPath, dirents); + return dirents; + } + } + } + const entries = readdirSync(dirPath, { withFileTypes: true }); dirEntryCache.set(dirPath, entries); return entries; @@ -28,6 +99,20 @@ function cachedReaddirWithTypes(dirPath: string): Dirent[] { function cachedReaddir(dirPath: string): string[] { const cached = dirListCache.get(dirPath); if (cached) return cached; + + // Try native tree cache for paths under .gsd/ + if (nativeTreeBase) { + const key = nativeTreeKey(dirPath, nativeTreeBase); + if (key && nativeTreeCache) { + const treeEntries = nativeTreeCache.get(key); + if (treeEntries) { + const names = treeEntries.map(e => e.name); + dirListCache.set(dirPath, names); + return names; + } + } + } + const entries = readdirSync(dirPath); dirListCache.set(dirPath, entries); return entries; @@ -41,6 +126,8 @@ function cachedReaddir(dirPath: string): string[] { export function clearPathCache(): void { dirEntryCache.clear(); dirListCache.clear(); + nativeTreeCache = null; + nativeTreeBase = null; } // ─── Name Builders ───────────────────────────────────────────────────────── @@ -160,6 +247,7 @@ export const GSD_ROOT_FILES = { QUEUE: "QUEUE.md", STATE: "STATE.md", REQUIREMENTS: "REQUIREMENTS.md", + OVERRIDES: "OVERRIDES.md", } as const; export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; @@ -170,6 +258,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { QUEUE: "queue.md", STATE: "state.md", REQUIREMENTS: "requirements.md", + OVERRIDES: "overrides.md", }; export function gsdRoot(basePath: string): string { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index f44078da0..b4db977b1 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { getAgentDir } from "@gsd/pi-coding-agent"; import type { GitPreferences } from "./git-service.js"; -import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences } from "./types.js"; +import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences, TokenProfile, InlineLevel, PhaseSkipPreferences } from "./types.js"; import { VALID_BRANCH_NAME } from "./git-service.js"; const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); @@ -15,6 +15,31 @@ const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.m const PROJECT_PREFERENCES_PATH_UPPERCASE = join(process.cwd(), ".gsd", "PREFERENCES.md"); const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]); +/** All recognized top-level keys in GSDPreferences. Used to detect typos / stale config. */ +const KNOWN_PREFERENCE_KEYS = new Set([ + "version", + "always_use_skills", + "prefer_skills", + "avoid_skills", + "skill_rules", + "custom_instructions", + "models", + "skill_discovery", + "auto_supervisor", + "uat_dispatch", + "unique_milestone_ids", + "budget_ceiling", + "budget_enforcement", + "context_pause_threshold", + "notifications", + "remote_questions", + "git", + "post_unit_hooks", + "pre_dispatch_hooks", + "token_profile", + "phases", +]); + export interface GSDSkillRule { when: string; use?: string[]; @@ -43,7 +68,9 @@ export interface GSDModelConfig { research?: string; planning?: string; execution?: string; + execution_simple?: string; completion?: string; + subagent?: string; } /** @@ -54,7 +81,9 @@ export interface GSDModelConfigV2 { research?: string | GSDPhaseModelConfig; planning?: string | GSDPhaseModelConfig; execution?: string | GSDPhaseModelConfig; + execution_simple?: string | GSDPhaseModelConfig; completion?: string | GSDPhaseModelConfig; + subagent?: string | GSDPhaseModelConfig; } /** Normalized model selection with resolved fallbacks */ @@ -86,7 +115,7 @@ export interface GSDPreferences { avoid_skills?: string[]; skill_rules?: GSDSkillRule[]; custom_instructions?: string[]; - models?: GSDModelConfig; + models?: GSDModelConfig | GSDModelConfigV2; skill_discovery?: SkillDiscoveryMode; auto_supervisor?: AutoSupervisorConfig; uat_dispatch?: boolean; @@ -99,12 +128,16 @@ export interface GSDPreferences { git?: GitPreferences; post_unit_hooks?: PostUnitHookConfig[]; pre_dispatch_hooks?: PreDispatchHookConfig[]; + token_profile?: TokenProfile; + phases?: PhaseSkipPreferences; } export interface LoadedGSDPreferences { path: string; scope: "global" | "project"; preferences: GSDPreferences; + /** Validation warnings (unknown keys, type mismatches, deprecations). Empty when preferences are clean. */ + warnings?: string[]; } export function getGlobalGSDPreferencesPath(): string { @@ -138,10 +171,16 @@ export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null { if (!globalPreferences) return projectPreferences; if (!projectPreferences) return globalPreferences; + const mergedWarnings = [ + ...(globalPreferences.warnings ?? []), + ...(projectPreferences.warnings ?? []), + ]; + return { path: projectPreferences.path, scope: "project", preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences), + ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}), }; } @@ -367,15 +406,20 @@ function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedG const preferences = parsePreferencesMarkdown(raw); if (!preferences) return null; + const validation = validatePreferences(preferences); + const allWarnings = [...validation.warnings, ...validation.errors]; + return { path, scope, - preferences, + preferences: validation.preferences, + ...(allWarnings.length > 0 ? { warnings: allWarnings } : {}), }; } -function parsePreferencesMarkdown(content: string): GSDPreferences | null { - const match = content.match(/^---\n([\s\S]*?)\n---/); +/** @internal Exported for testing only */ +export function parsePreferencesMarkdown(content: string): GSDPreferences | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return null; return parseFrontmatterBlock(match[1]); } @@ -392,6 +436,9 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { const indent = line.match(/^\s*/)?.[0].length ?? 0; const trimmed = line.trim(); + // Skip comment lines (standalone YAML comments) + if (trimmed.startsWith("#")) continue; + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) { stack.pop(); } @@ -401,7 +448,8 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { if (!keyMatch) continue; const [, key, remainder] = keyMatch; - const valuePart = remainder.trim(); + // Strip inline comments from the value portion + const valuePart = remainder.replace(/\s+#.*$/, "").trim(); if (valuePart === "") { const nextLine = lines[i + 1] ?? ""; @@ -424,7 +472,12 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { const nextCandidateIndent = nextCandidate.match(/^\s*/)?.[0].length ?? 0; const nextCandidateTrimmed = nextCandidate.trim(); - if (itemText.includes(":") || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) { + // Treat an array item as a structured object only when: + // a) It looks like a YAML key-value pair (key starts with [A-Za-z0-9_]+:), OR + // b) The next line is indented deeper (nested block under this item). + // Bare colons (e.g. "qwen/qwen3-coder:free") are NOT key-value pairs. + const looksLikeKeyValue = /^[A-Za-z0-9_]+:/.test(itemText); + if (looksLikeKeyValue || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) { const obj: Record = {}; const firstMatch = itemText.match(/^([A-Za-z0-9_]+):(.*)$/); if (firstMatch) { @@ -489,17 +542,23 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { } function parseScalar(value: string): unknown { - if (value === "true") return true; - if (value === "false") return false; + // Strip inline YAML comments: " # comment" (# preceded by whitespace). + // Quoted strings are returned as-is (the comment is inside quotes). + const quoteMatch = value.match(/^(['"])(.*)(\1)$/); + if (quoteMatch) return quoteMatch[2]; + + const stripped = value.replace(/\s+#.*$/, ""); + if (stripped === "true") return true; + if (stripped === "false") return false; // Recognize empty array/object literals (with or without surrounding quotes) - const unquoted = value.replace(/^['\"]|['\"]$/g, ""); + const unquoted = stripped.replace(/^['\"]|['\"]$/g, ""); if (unquoted === "[]") return []; if (unquoted === "{}") return {}; - if (/^-?\d+$/.test(value)) { - const n = Number(value); + if (/^-?\d+$/.test(stripped)) { + const n = Number(stripped); // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss if (Number.isSafeInteger(n)) return n; - return value; + return stripped; } return unquoted; } @@ -580,11 +639,19 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode case "execute-task": phaseConfig = m.execution; break; + case "execute-task-simple": + phaseConfig = m.execution_simple ?? m.execution; + break; case "complete-slice": case "run-uat": phaseConfig = m.completion; break; default: + // Subagent unit types (e.g., "subagent", "subagent/scout") + if (unitType === "subagent" || unitType.startsWith("subagent/")) { + phaseConfig = m.subagent; + break; + } return undefined; } @@ -619,6 +686,73 @@ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig { }; } +// ─── Token Profile Resolution ───────────────────────────────────────────── + +const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality"]); + +/** + * Resolve profile defaults for a given token profile tier. + * Returns a partial GSDPreferences that is used as the base layer — + * explicit user preferences always override these defaults. + */ +export function resolveProfileDefaults(profile: TokenProfile): Partial { + switch (profile) { + case "budget": + return { + models: { + planning: "claude-sonnet-4-5-20250514", + execution: "claude-sonnet-4-5-20250514", + execution_simple: "claude-haiku-4-5-20250414", + completion: "claude-haiku-4-5-20250414", + subagent: "claude-haiku-4-5-20250414", + }, + phases: { + skip_research: true, + skip_reassess: true, + skip_slice_research: true, + }, + }; + case "balanced": + return { + models: { + subagent: "claude-sonnet-4-5-20250514", + }, + phases: { + skip_slice_research: true, + }, + }; + case "quality": + return { + models: {}, + phases: {}, + }; + } +} + +/** + * Resolve the effective token profile from preferences. + * Returns "balanced" when no profile is set (D046). + */ +export function resolveEffectiveProfile(): TokenProfile { + const prefs = loadEffectiveGSDPreferences(); + const profile = prefs?.preferences.token_profile; + if (profile && VALID_TOKEN_PROFILES.has(profile)) return profile; + return "balanced"; +} + +/** + * Resolve the inline level from the active token profile. + * budget → minimal, balanced → standard, quality → full. + */ +export function resolveInlineLevel(): InlineLevel { + const profile = resolveEffectiveProfile(); + switch (profile) { + case "budget": return "minimal"; + case "balanced": return "standard"; + case "quality": return "full"; + } +} + function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences { return { version: override.version ?? base.version, @@ -633,6 +767,11 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, unique_milestone_ids: override.unique_milestone_ids ?? base.unique_milestone_ids, budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, + budget_enforcement: override.budget_enforcement ?? base.budget_enforcement, + context_pause_threshold: override.context_pause_threshold ?? base.context_pause_threshold, + notifications: (base.notifications || override.notifications) + ? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) } + : undefined, remote_questions: override.remote_questions ? { ...(base.remote_questions ?? {}), ...override.remote_questions } : base.remote_questions, @@ -641,6 +780,10 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr : undefined, post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks), pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks), + token_profile: override.token_profile ?? base.token_profile, + phases: (base.phases || override.phases) + ? { ...(base.phases ?? {}), ...(override.phases ?? {}) } + : undefined, }; } @@ -653,6 +796,13 @@ export function validatePreferences(preferences: GSDPreferences): { const warnings: string[] = []; const validated: GSDPreferences = {}; + // ─── Unknown Key Detection ────────────────────────────────────────── + for (const key of Object.keys(preferences)) { + if (!KNOWN_PREFERENCE_KEYS.has(key)) { + warnings.push(`unknown preference key "${key}" — ignored`); + } + } + if (preferences.version !== undefined) { if (preferences.version === 1) { validated.version = 1; @@ -730,6 +880,94 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── Budget Enforcement ────────────────────────────────────────────── + if (preferences.budget_enforcement !== undefined) { + const validModes = new Set(["warn", "pause", "halt"]); + if (typeof preferences.budget_enforcement === "string" && validModes.has(preferences.budget_enforcement)) { + validated.budget_enforcement = preferences.budget_enforcement; + } else { + errors.push(`budget_enforcement must be one of: warn, pause, halt`); + } + } + + // ─── Token Profile ───────────────────────────────────────────────── + if (preferences.token_profile !== undefined) { + if (typeof preferences.token_profile === "string" && VALID_TOKEN_PROFILES.has(preferences.token_profile as TokenProfile)) { + validated.token_profile = preferences.token_profile as TokenProfile; + } else { + errors.push(`token_profile must be one of: budget, balanced, quality`); + } + } + + // ─── Phase Skip Preferences ───────────────────────────────────────── + if (preferences.phases !== undefined) { + if (typeof preferences.phases === "object" && preferences.phases !== null) { + const validatedPhases: PhaseSkipPreferences = {}; + const p = preferences.phases as Record; + if (p.skip_research !== undefined) validatedPhases.skip_research = !!p.skip_research; + if (p.skip_reassess !== undefined) validatedPhases.skip_reassess = !!p.skip_reassess; + if (p.skip_slice_research !== undefined) validatedPhases.skip_slice_research = !!p.skip_slice_research; + // Warn on unknown phase keys + const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research"]); + for (const key of Object.keys(p)) { + if (!knownPhaseKeys.has(key)) { + warnings.push(`unknown phases key "${key}" — ignored`); + } + } + validated.phases = validatedPhases; + } else { + errors.push(`phases must be an object`); + } + } + + // ─── Context Pause Threshold ──────────────────────────────────────── + if (preferences.context_pause_threshold !== undefined) { + const raw = preferences.context_pause_threshold; + if (typeof raw === "number" && Number.isFinite(raw)) { + validated.context_pause_threshold = raw; + } else if (typeof raw === "string" && Number.isFinite(Number(raw))) { + validated.context_pause_threshold = Number(raw); + } else { + errors.push("context_pause_threshold must be a finite number"); + } + } + + // ─── Models ───────────────────────────────────────────────────────── + if (preferences.models !== undefined) { + if (preferences.models && typeof preferences.models === "object") { + validated.models = preferences.models; + } else { + errors.push("models must be an object"); + } + } + + // ─── Auto Supervisor ──────────────────────────────────────────────── + if (preferences.auto_supervisor !== undefined) { + if (preferences.auto_supervisor && typeof preferences.auto_supervisor === "object") { + validated.auto_supervisor = preferences.auto_supervisor; + } else { + errors.push("auto_supervisor must be an object"); + } + } + + // ─── Notifications ────────────────────────────────────────────────── + if (preferences.notifications !== undefined) { + if (preferences.notifications && typeof preferences.notifications === "object") { + validated.notifications = preferences.notifications; + } else { + errors.push("notifications must be an object"); + } + } + + // ─── Remote Questions ─────────────────────────────────────────────── + if (preferences.remote_questions !== undefined) { + if (preferences.remote_questions && typeof preferences.remote_questions === "object") { + validated.remote_questions = preferences.remote_questions; + } else { + errors.push("remote_questions must be an object"); + } + } + // ─── Post-Unit Hooks ───────────────────────────────────────────────── if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) { const validHooks: PostUnitHookConfig[] = []; @@ -917,11 +1155,19 @@ export function validatePreferences(preferences: GSDPreferences): { errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)"); } } - // Deprecated: isolation and merge_to_main are ignored (branchless architecture). - // Emit warnings so users know to remove them from preferences. if (g.isolation !== undefined) { - warnings.push("git.isolation is deprecated — worktree isolation is now always enabled. Remove this setting."); + const validIsolation = new Set(["worktree", "branch"]); + if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) { + git.isolation = g.isolation as "worktree" | "branch"; + } else { + errors.push("git.isolation must be one of: worktree, branch"); + } } + if (g.commit_docs !== undefined) { + if (typeof g.commit_docs === "boolean") git.commit_docs = g.commit_docs; + else errors.push("git.commit_docs must be a boolean"); + } + // Deprecated: merge_to_main is ignored (branchless architecture). if (g.merge_to_main !== undefined) { warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting."); } diff --git a/src/resources/extensions/gsd/prompts/complete-milestone.md b/src/resources/extensions/gsd/prompts/complete-milestone.md index 2d3d51d1c..a7e228fcf 100644 --- a/src/resources/extensions/gsd/prompts/complete-milestone.md +++ b/src/resources/extensions/gsd/prompts/complete-milestone.md @@ -2,6 +2,14 @@ You are executing GSD auto-mode. ## UNIT: Complete Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + +## Your Role in the Pipeline + +All slices are done. You are closing out the milestone — verifying that the assembled work actually delivers the promised outcome, writing the milestone summary, and updating project state. The milestone summary is the final record. After you finish, the system merges the worktree back to the integration branch. If there are queued milestones, the next one starts its own research → plan → execute cycle from a clean slate — the milestone summary is how it learns what was already built. + 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}} diff --git a/src/resources/extensions/gsd/prompts/complete-slice.md b/src/resources/extensions/gsd/prompts/complete-slice.md index d96e02474..66070bdc9 100644 --- a/src/resources/extensions/gsd/prompts/complete-slice.md +++ b/src/resources/extensions/gsd/prompts/complete-slice.md @@ -2,6 +2,16 @@ You are executing GSD auto-mode. ## UNIT: Complete Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + +## Your Role in the Pipeline + +Executor agents built each task and wrote task summaries. You are the closer — verify the assembled work actually delivers the slice goal, then compress everything into a slice summary. After you finish, a **reassess-roadmap agent** reads your slice summary to decide if the remaining roadmap still makes sense. The slice summary is also the primary record of what this slice achieved — future slice researchers and planners read it as a dependency summary when their work builds on yours. + +Write the summary for those downstream readers. What did this slice actually deliver? What patterns did it establish? What should the next slice know? + 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}} diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 5757b3d6e..4ae7255cd 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -2,7 +2,13 @@ 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. +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + +A researcher explored the codebase and a planner decomposed the work — you are the executor. The task plan below is your authoritative contract. It contains the specific files, steps, and verification you need. Don't re-research or re-plan — build what the plan says, verify it works, and document what happened. + +{{overridesSection}} {{resumeSection}} @@ -54,7 +60,7 @@ Then: 16. Do not commit manually — the system auto-commits your changes after this unit completes. 17. Update `.gsd/STATE.md` -You are on the slice branch. All work stays here. +All work stays in your working directory: `{{workingDirectory}}`. **You MUST mark {{taskId}} as `[x]` in `{{planPath}}` AND write `{{taskSummaryPath}}` before finishing.** diff --git a/src/resources/extensions/gsd/prompts/guided-complete-slice.md b/src/resources/extensions/gsd/prompts/guided-complete-slice.md index 284eb28c2..edcf6dc9a 100644 --- a/src/resources/extensions/gsd/prompts/guided-complete-slice.md +++ b/src/resources/extensions/gsd/prompts/guided-complete-slice.md @@ -1,3 +1,3 @@ -Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. All tasks are done. Use the **Slice Summary** and **UAT** output templates below. 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 into the integration branch automatically. +Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Your working directory is `{{workingDirectory}}` — all file operations must use this path. All tasks are done. Your slice summary is the primary record of what was built — downstream agents (reassess-roadmap, future slice researchers) read it to understand what this slice delivered and what to watch out for. Use the **Slice Summary** and **UAT** output templates below. 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, Do not commit or merge manually — the system handles this after the unit completes. {{inlinedTemplates}} diff --git a/src/resources/extensions/gsd/prompts/guided-plan-milestone.md b/src/resources/extensions/gsd/prompts/guided-plan-milestone.md index 8f1b1dcc6..c16ae5c38 100644 --- a/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +++ b/src/resources/extensions/gsd/prompts/guided-plan-milestone.md @@ -26,4 +26,6 @@ Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` After writing the roadmap, analyze the slices and their boundary maps for external service dependencies (third-party APIs, SaaS platforms, cloud providers, databases requiring credentials, OAuth providers, etc.). If this milestone requires any external API keys or secrets, use the **Secrets Manifest** output template below for the expected format and write `{{secretsOutputPath}}` listing every predicted secret as an H3 section with the Service name, a direct Dashboard URL to the console page where the key is created, a Format hint showing what the key looks like, Status set to `pending`, and Destination (`dotenv`, `vercel`, or `convex`). Include numbered step-by-step guidance for obtaining each key. If this milestone does not require any external API keys or secrets, skip this step entirely — do not create an empty manifest. +**You MUST update `.gsd/STATE.md`** after writing the roadmap (and secrets manifest if applicable). This is required for auto-mode to continue. + {{inlinedTemplates}} diff --git a/src/resources/extensions/gsd/prompts/guided-research-slice.md b/src/resources/extensions/gsd/prompts/guided-research-slice.md index e0f010a13..0707d879b 100644 --- a/src/resources/extensions/gsd/prompts/guided-research-slice.md +++ b/src/resources/extensions/gsd/prompts/guided-research-slice.md @@ -1,4 +1,6 @@ -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`. Use the **Research** output template below. Write `{{sliceId}}-RESEARCH.md` in the slice directory with summary, don't-hand-roll, common pitfalls, and relevant code sections. +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` — skip this for libraries already used in the codebase. Use the **Research** output template below. Write `{{sliceId}}-RESEARCH.md` in the slice directory. + +**You are the scout.** A planner agent reads your output in a fresh context to decompose this slice into tasks. Write for the planner — surface key files, where the work divides naturally, what to build first, and how to verify. If the research doc is vague, the planner re-explores code you already read. If it's precise, the planner decomposes immediately. ## Strategic Questions to Answer diff --git a/src/resources/extensions/gsd/prompts/plan-milestone.md b/src/resources/extensions/gsd/prompts/plan-milestone.md index d10d2381b..ea70a0467 100644 --- a/src/resources/extensions/gsd/prompts/plan-milestone.md +++ b/src/resources/extensions/gsd/prompts/plan-milestone.md @@ -2,10 +2,20 @@ You are executing GSD auto-mode. ## UNIT: Plan Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} +## Your Role in the Pipeline + +A **researcher agent** already explored the codebase and documented findings in the milestone research doc (inlined above, if present). It identified key files, technology choices, constraints, and risks. **Trust the research.** Your job is strategic decomposition — turning findings into an ordered set of demoable slices — not re-exploration. Don't read code files the research already summarized unless something is ambiguous or missing. + +After you finish, each slice goes through its own research → plan → execute cycle. Slice researchers dive deeper into the specific area. Slice planners decompose into tasks. Executors build each task. Your roadmap sets the strategic frame for all of them. + Narrate your decomposition reasoning — why you're grouping work this way, what risks are driving the order, what verification strategy you're choosing and why. Then: diff --git a/src/resources/extensions/gsd/prompts/plan-slice.md b/src/resources/extensions/gsd/prompts/plan-slice.md index a4ae2e63f..fe5036db4 100644 --- a/src/resources/extensions/gsd/prompts/plan-slice.md +++ b/src/resources/extensions/gsd/prompts/plan-slice.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Plan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} @@ -12,6 +16,12 @@ Pay particular attention to **Forward Intelligence** sections — they contain h {{dependencySummaries}} +## Your Role in the Pipeline + +A **researcher agent** already explored the codebase and documented findings in the slice research doc (inlined above, if present). It identified key files, build order, constraints, and verification approach. **Trust the research.** Your job is decomposition — turning findings into executable tasks — not re-exploration. Don't read code files the research already summarized unless something is ambiguous or missing from its findings. + +After you finish, **executor agents** implement each task in isolated fresh context windows. They see only their task plan, the slice plan excerpt (goal/demo/verification), and compressed summaries of prior tasks. They do not see the research doc, the roadmap, or REQUIREMENTS.md. Everything an executor needs must be in the task plan itself — file paths, specific steps, expected inputs and outputs. + Narrate your decomposition reasoning — why you're grouping work this way, what risks are driving the order, what verification strategy you're choosing and why. Keep the narration proportional to the work — a simple slice doesn't need a long justification. **Right-size the plan.** If the slice is simple enough to be 1 task, plan 1 task. Don't split into multiple tasks just because you can identify sub-steps. Don't fill in sections with "None" when the section doesn't apply — omit them entirely. The plan's job is to guide execution, not to fill a template. @@ -48,7 +58,7 @@ Then: 10. Commit: `docs({{sliceId}}): add slice plan` 11. 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. +The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. All work stays in your working directory: `{{workingDirectory}}`. **You MUST write the file `{{outputPath}}` before finishing.** diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md index eeb001fd4..933e6a580 100644 --- a/src/resources/extensions/gsd/prompts/reassess-roadmap.md +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -2,6 +2,16 @@ You are executing GSD auto-mode. ## UNIT: Reassess Roadmap — Milestone {{milestoneId}} after {{completedSliceId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + +## Your Role in the Pipeline + +A slice just completed. The **complete-slice agent** verified the work and wrote a slice summary. You decide whether the remaining roadmap still makes sense given what was actually built. If you change the roadmap, the next slice's **researcher** and **planner** agents work from your updated version. If you confirm it's fine, the pipeline moves to the next slice immediately. + +Your assessment should be fast and decisive. Most of the time the plan is still good. + 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}} diff --git a/src/resources/extensions/gsd/prompts/replan-slice.md b/src/resources/extensions/gsd/prompts/replan-slice.md index 6b6ae86af..0548b9d08 100644 --- a/src/resources/extensions/gsd/prompts/replan-slice.md +++ b/src/resources/extensions/gsd/prompts/replan-slice.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Replan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + 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. diff --git a/src/resources/extensions/gsd/prompts/research-milestone.md b/src/resources/extensions/gsd/prompts/research-milestone.md index 70bc03a29..b67516e3b 100644 --- a/src/resources/extensions/gsd/prompts/research-milestone.md +++ b/src/resources/extensions/gsd/prompts/research-milestone.md @@ -2,23 +2,32 @@ You are executing GSD auto-mode. ## UNIT: Research Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} +## Your Role in the Pipeline + +You are the first deep look at this milestone. A **roadmap planner** reads your output to decide how to slice the work — what to build first, how to order by risk, what boundaries to draw between slices. Then individual slice researchers and planners dive deeper into each slice. Your research sets the strategic direction for all of them. + +Write for the roadmap planner. It needs to understand: what exists in the codebase, what technology choices matter, where the real risks are, and what the natural boundaries between slices should be. + +## Calibrate Depth + +A milestone adding a small feature to an established codebase needs targeted research — check the relevant code, confirm the approach, note constraints. A milestone introducing new technology, building a new system, or spanning multiple unfamiliar subsystems needs deep research — explore broadly, look up docs, investigate alternatives. Match your effort to the actual uncertainty, not the template's section count. Include only sections that have real content. + Then research the codebase and relevant technologies. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach. 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. Use the **Research** output template from the inlined context above +4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase +5. Use the **Research** output template from the inlined context above — include only sections that have real content 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 +7. Write `{{outputPath}}` ## Strategic Questions to Answer diff --git a/src/resources/extensions/gsd/prompts/research-slice.md b/src/resources/extensions/gsd/prompts/research-slice.md index 192d30e0e..ee8fd7055 100644 --- a/src/resources/extensions/gsd/prompts/research-slice.md +++ b/src/resources/extensions/gsd/prompts/research-slice.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Research Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} @@ -12,13 +16,37 @@ Pay particular attention to **Forward Intelligence** sections — they contain h {{dependencySummaries}} -Then research what this slice needs. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach. +## Your Role in the Pipeline + +You are the scout. After you finish, a **planner agent** reads your output in a fresh context with no memory of your exploration. It uses your findings to decompose this slice into executable tasks — deciding what files change, what order to build things, how to verify the work. Then **executor agents** build each task in isolated context windows. + +Write for the planner, not for a human. The planner needs: +- **What files exist and what they do** — so it can scope tasks to specific files +- **Where the natural seams are** — where work divides into independent units +- **What to build or prove first** — what's riskiest, what unblocks everything else +- **How to verify the result** — what commands, tests, or checks confirm the slice works + +If the research doc is vague, the planner will waste its context re-exploring code you already read. If it's precise, the planner can decompose immediately. + +## Calibrate Depth + +Read the slice title, the roadmap excerpt, and any milestone research above. Ask: does this slice involve unfamiliar technology, risky integration, novel architecture, or ambiguous requirements? Or is it straightforward application of known patterns to known code? + +- **Deep research** — new technology, unfamiliar APIs, risky integration, multiple viable approaches, or ambiguous scope. Explore broadly, look up docs, check libraries, investigate constraints. Write all template sections. This is the default when you're genuinely uncertain. +- **Targeted research** — known technology but new to this codebase, or moderately complex integration. Explore the relevant code, check one or two libraries, identify constraints. Omit Don't Hand-Roll and Sources if nothing applies. +- **Light research** — well-understood work using established patterns already in the codebase (wiring up existing APIs, adding standard UI components, CRUD operations, configuration changes). Read the relevant files to confirm the pattern, note any constraints, write Summary + Recommendation + Implementation Landscape. Skip the rest. A light research doc can be 15-20 lines. Don't manufacture pitfalls or risks for work that doesn't have them. + +An honest "this is straightforward, here's the pattern to follow" is more valuable than invented complexity. + +## Steps + +Research what this slice needs. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach. 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. Use the **Research** output template from the inlined context above +4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase +5. Use the **Research** output template from the inlined context above — include only sections that have real content 6. Write `{{outputPath}}` The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file. diff --git a/src/resources/extensions/gsd/prompts/rewrite-docs.md b/src/resources/extensions/gsd/prompts/rewrite-docs.md new file mode 100644 index 000000000..d81632456 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/rewrite-docs.md @@ -0,0 +1,32 @@ +You are executing GSD auto-mode. + +## UNIT: Rewrite Documents — Apply Override(s) for Milestone {{milestoneId}} ("{{milestoneTitle}}") + +An override was issued by the user that changes a fundamental decision or approach. Your job is to propagate this change across all active planning documents so they are internally consistent and future tasks execute correctly. + +## Active Override(s) + +{{overrideContent}} + +## Documents to Review and Update + +{{documentList}} + +## Instructions + +1. Read each document listed above +2. Identify all references to the overridden decision/approach +3. Rewrite each document to reflect the new direction: + - For task plans (T##-PLAN.md): do NOT modify completed tasks (`[x]`) — they are historical. Rewrite incomplete tasks (`[ ]`) to align with the override. If a task is no longer needed, remove it. If new tasks are needed, add them following the ID sequence. + - For DECISIONS.md: append a new decision entry documenting the override and why. Do NOT delete prior decisions — mark them as superseded with a note. + - For slice plans (S##-PLAN.md): update Goal, Demo, and Verification sections if affected. Update Files Likely Touched if the override changes scope. Do NOT modify completed task entries. + - For REQUIREMENTS.md: update requirement descriptions if the override changes what "done" means, but do not remove requirements. + - For PROJECT.md: update if the override changes project-level facts. + - Milestone context files are reference only — do not modify them. +4. Mark all active overrides as resolved: change `**Scope:** active` to `**Scope:** resolved` in `{{overridesPath}}` +5. Do not commit manually — the system auto-commits your changes after this unit completes. +6. Update `.gsd/STATE.md` + +**You MUST update the relevant documents AND mark overrides as resolved in `{{overridesPath}}` before finishing.** + +When done, say: "Override applied across all documents." diff --git a/src/resources/extensions/gsd/prompts/run-uat.md b/src/resources/extensions/gsd/prompts/run-uat.md index 8e54ab352..f00d2cb4c 100644 --- a/src/resources/extensions/gsd/prompts/run-uat.md +++ b/src/resources/extensions/gsd/prompts/run-uat.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Run UAT — {{milestoneId}}/{{sliceId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below. Start working immediately without re-reading these files. {{inlinedContext}} diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index fc50ad77c..ed19ce52f 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -62,19 +62,24 @@ Titles live inside file content (headings, frontmatter), not in file or director ``` .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) + PROJECT.md (living doc - what the project is right now) + REQUIREMENTS.md (requirement contract - tracks active/validated/deferred/out-of-scope) + DECISIONS.md (append-only register of architectural and pattern decisions) + OVERRIDES.md (user-issued overrides that supersede plan content via /gsd steer) + QUEUE.md (append-only log of queued milestones via /gsd queue) STATE.md + runtime/ (system-managed — dispatch state, do not edit) + activity/ (system-managed — JSONL execution logs, do not edit) + worktrees/ (system-managed — auto-mode worktree checkouts, see below) milestones/ M001/ - M001-CONTEXT.md + M001-CONTEXT.md (milestone brief — scope, goals, constraints. May not exist for early milestones) M001-RESEARCH.md M001-ROADMAP.md M001-SUMMARY.md slices/ S01/ - S01-CONTEXT.md (optional) + S01-CONTEXT.md (slice brief — optional, present when slice needed scoping discussion) S01-RESEARCH.md (optional) S01-PLAN.md S01-SUMMARY.md @@ -84,16 +89,22 @@ Titles live inside file content (headings, frontmatter), not in file or director T01-SUMMARY.md ``` +### Worktree Model + +All auto-mode work happens inside a worktree at `.gsd/worktrees//`. This is a full git worktree on the `milestone/` branch — it has its own working copy of the project and its own `.gsd/` directory. Slices commit sequentially on this branch; there are no per-slice branches. When a milestone completes, the worktree is merged back to the integration branch. + +**If you are executing in auto-mode, your working directory is already set to the worktree.** Use relative paths or the path shown in the Working Directory section of your prompt. Do not navigate to any other copy of the project. + ### Conventions - **PROJECT.md** is a living document describing what the project is right now - current state only, updated at slice completion when stale +- **REQUIREMENTS.md** tracks the requirement contract — requirements move between Active, Validated, Deferred, Blocked, and Out of Scope as slices prove or invalidate them. Update at slice completion when evidence supports a status change. - **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 +- **CONTEXT.md** files (milestone or slice level) capture the brief — scope, goals, constraints, and key decisions from discussion. When present, they are the authoritative source for what a milestone or slice is trying to achieve. Read them before planning or executing. - **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` (or `gsd//M001/S01` when inside a worktree) -- Slices are squash-merged to the integration branch when complete (this is the branch GSD was started from — often `main`, but could be a feature branch like `f-123-new-thing`) - 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 diff --git a/src/resources/extensions/gsd/provider-error-pause.ts b/src/resources/extensions/gsd/provider-error-pause.ts new file mode 100644 index 000000000..954c1774b --- /dev/null +++ b/src/resources/extensions/gsd/provider-error-pause.ts @@ -0,0 +1,12 @@ +export type ProviderErrorPauseUI = { + notify(message: string, level?: "info" | "warning" | "error" | "success"): void; +}; + +export async function pauseAutoForProviderError( + ui: ProviderErrorPauseUI, + errorDetail: string, + pause: () => Promise, +): Promise { + ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning"); + await pause(); +} diff --git a/src/resources/extensions/gsd/routing-history.ts b/src/resources/extensions/gsd/routing-history.ts new file mode 100644 index 000000000..a4fe81ea7 --- /dev/null +++ b/src/resources/extensions/gsd/routing-history.ts @@ -0,0 +1,290 @@ +// GSD Extension — Routing History (Adaptive Learning) +// Tracks success/failure per tier per unit-type pattern to improve +// classification accuracy over time. + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { gsdRoot } from "./paths.js"; +import type { ComplexityTier } from "./types.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface TierOutcome { + success: number; + fail: number; +} + +export interface PatternHistory { + light: TierOutcome; + standard: TierOutcome; + heavy: TierOutcome; +} + +export interface RoutingHistoryData { + version: 1; + /** Keyed by pattern string, e.g. "execute-task:docs" or "complete-slice" */ + patterns: Record; + /** User feedback entries (from /gsd:rate-unit) */ + feedback: FeedbackEntry[]; + /** Last updated timestamp */ + updatedAt: string; +} + +export interface FeedbackEntry { + unitType: string; + unitId: string; + tier: ComplexityTier; + rating: "over" | "under" | "ok"; + timestamp: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const HISTORY_FILE = "routing-history.json"; +const ROLLING_WINDOW = 50; // only consider last N entries per pattern +const FAILURE_THRESHOLD = 0.20; // >20% failure rate triggers tier bump +const FEEDBACK_WEIGHT = 2; // feedback signals count 2x vs automatic + +// ─── In-Memory State ───────────────────────────────────────────────────────── + +let history: RoutingHistoryData | null = null; +let historyBasePath = ""; + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Initialize routing history for a project. + */ +export function initRoutingHistory(base: string): void { + historyBasePath = base; + history = loadHistory(base); +} + +/** + * Reset routing history state. + */ +export function resetRoutingHistory(): void { + history = null; + historyBasePath = ""; +} + +/** + * Record the outcome of a unit dispatch. + * + * @param unitType The unit type (e.g. "execute-task") + * @param tier The tier that was used + * @param success Whether the unit completed successfully + * @param tags Optional tags from task metadata (e.g. ["docs", "test"]) + */ +export function recordOutcome( + unitType: string, + tier: ComplexityTier, + success: boolean, + tags?: string[], +): void { + if (!history) return; + + // Record for the base unit type + const basePattern = unitType; + ensurePattern(basePattern); + const outcome = history.patterns[basePattern][tier]; + if (success) outcome.success++; + else outcome.fail++; + + // Record for tag-specific patterns (e.g. "execute-task:docs") + if (tags && tags.length > 0) { + for (const tag of tags) { + const tagPattern = `${unitType}:${tag}`; + ensurePattern(tagPattern); + const tagOutcome = history.patterns[tagPattern][tier]; + if (success) tagOutcome.success++; + else tagOutcome.fail++; + } + } + + // Apply rolling window — cap total entries per tier per pattern + for (const pattern of Object.keys(history.patterns)) { + const p = history.patterns[pattern]; + for (const t of ["light", "standard", "heavy"] as const) { + const total = p[t].success + p[t].fail; + if (total > ROLLING_WINDOW) { + const scale = ROLLING_WINDOW / total; + p[t].success = Math.round(p[t].success * scale); + p[t].fail = Math.round(p[t].fail * scale); + } + } + } + + history.updatedAt = new Date().toISOString(); + saveHistory(historyBasePath, history); +} + +/** + * Record user feedback for the last completed unit. + */ +export function recordFeedback( + unitType: string, + unitId: string, + tier: ComplexityTier, + rating: "over" | "under" | "ok", +): void { + if (!history) return; + + history.feedback.push({ + unitType, + unitId, + tier, + rating, + timestamp: new Date().toISOString(), + }); + + // Cap feedback array at 200 entries + if (history.feedback.length > 200) { + history.feedback = history.feedback.slice(-200); + } + + // Apply feedback as weighted outcome + const pattern = unitType; + ensurePattern(pattern); + + if (rating === "over") { + // User says this could have used a simpler model → record as success at current tier + // and also as success at one tier lower (encourages more downgrading) + const lower = tierBelow(tier); + if (lower) { + const outcomes = history.patterns[pattern][lower]; + outcomes.success += FEEDBACK_WEIGHT; + } + } else if (rating === "under") { + // User says this needed a better model → record as failure at current tier + const outcomes = history.patterns[pattern][tier]; + outcomes.fail += FEEDBACK_WEIGHT; + } + // "ok" = no adjustment needed + + history.updatedAt = new Date().toISOString(); + saveHistory(historyBasePath, history); +} + +/** + * Get the recommended tier adjustment for a given pattern. + * Returns the tier to bump to if the failure rate exceeds threshold, + * or null if no adjustment is needed. + */ +export function getAdaptiveTierAdjustment( + unitType: string, + currentTier: ComplexityTier, + tags?: string[], +): ComplexityTier | null { + if (!history) return null; + + // Check tag-specific patterns first (more specific) + if (tags && tags.length > 0) { + for (const tag of tags) { + const tagPattern = `${unitType}:${tag}`; + const adjustment = checkPatternFailureRate(tagPattern, currentTier); + if (adjustment) return adjustment; + } + } + + // Fall back to base pattern + return checkPatternFailureRate(unitType, currentTier); +} + +/** + * Clear all routing history (user-triggered reset). + */ +export function clearRoutingHistory(base: string): void { + history = createEmptyHistory(); + saveHistory(base, history); +} + +/** + * Get current history data (for display/debugging). + */ +export function getRoutingHistory(): RoutingHistoryData | null { + return history; +} + +// ─── Internal ──────────────────────────────────────────────────────────────── + +function checkPatternFailureRate( + pattern: string, + tier: ComplexityTier, +): ComplexityTier | null { + if (!history?.patterns[pattern]) return null; + + const outcomes = history.patterns[pattern][tier]; + const total = outcomes.success + outcomes.fail; + if (total < 3) return null; // Not enough data + + const failureRate = outcomes.fail / total; + if (failureRate > FAILURE_THRESHOLD) { + // Bump to next tier + return tierAbove(tier); + } + + return null; +} + +function tierAbove(tier: ComplexityTier): ComplexityTier | null { + switch (tier) { + case "light": return "standard"; + case "standard": return "heavy"; + case "heavy": return null; + } +} + +function tierBelow(tier: ComplexityTier): ComplexityTier | null { + switch (tier) { + case "light": return null; + case "standard": return "light"; + case "heavy": return "standard"; + } +} + +function ensurePattern(pattern: string): void { + if (!history) return; + if (!history.patterns[pattern]) { + history.patterns[pattern] = { + light: { success: 0, fail: 0 }, + standard: { success: 0, fail: 0 }, + heavy: { success: 0, fail: 0 }, + }; + } +} + +function createEmptyHistory(): RoutingHistoryData { + return { + version: 1, + patterns: {}, + feedback: [], + updatedAt: new Date().toISOString(), + }; +} + +function historyPath(base: string): string { + return join(gsdRoot(base), HISTORY_FILE); +} + +function loadHistory(base: string): RoutingHistoryData { + try { + const raw = readFileSync(historyPath(base), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed.version === 1 && parsed.patterns) { + return parsed as RoutingHistoryData; + } + } catch { + // File doesn't exist or is corrupt — start fresh + } + return createEmptyHistory(); +} + +function saveHistory(base: string, data: RoutingHistoryData): void { + try { + mkdirSync(gsdRoot(base), { recursive: true }); + writeFileSync(historyPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8"); + } catch { + // Non-fatal — don't let history failures break auto-mode + } +} diff --git a/src/resources/extensions/gsd/session-forensics.ts b/src/resources/extensions/gsd/session-forensics.ts index b3c5808f5..d7c34bb95 100644 --- a/src/resources/extensions/gsd/session-forensics.ts +++ b/src/resources/extensions/gsd/session-forensics.ts @@ -19,8 +19,9 @@ */ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; -import { execSync } from "node:child_process"; import { basename, join } from "node:path"; +import { nativeParseJsonlTail } from "./native-parser-bridge.js"; +import { nativeWorkingTreeStatus, nativeDiffStat } from "./native-git-bridge.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -210,11 +211,11 @@ export function extractTrace(entries: unknown[]): ExecutionTrace { function getGitChanges(basePath: string): string | null { try { - const status = execSync("git status --porcelain", { cwd: basePath, stdio: "pipe" }).toString().trim(); + const status = nativeWorkingTreeStatus(basePath); 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 diffStat = nativeDiffStat(basePath, "HEAD", "WORKDIR").summary; + const stagedStat = nativeDiffStat(basePath, "HEAD", "INDEX").summary; const parts: string[] = []; if (status) parts.push(`Status:\n${status}`); @@ -247,14 +248,21 @@ export function synthesizeCrashRecovery( // Primary source: surviving pi session file if (sessionFile && existsSync(sessionFile)) { - const stat = statSync(sessionFile, { throwIfNoEntry: false }); - const fileSize = stat?.size ?? 0; - // Skip files that would blow up memory; fall back to activity log - if (fileSize <= MAX_JSONL_BYTES * 2) { - const raw = readFileSync(sessionFile, "utf-8"); - const allEntries = parseJSONL(raw); - const sessionEntries = extractLastSession(allEntries); + // Try native JSONL parser first (handles arbitrary file sizes with constant memory) + const nativeResult = nativeParseJsonlTail(sessionFile, MAX_JSONL_BYTES); + if (nativeResult) { + const sessionEntries = extractLastSession(nativeResult.entries); trace = extractTrace(sessionEntries); + } else { + const stat = statSync(sessionFile, { throwIfNoEntry: false }); + const fileSize = stat?.size ?? 0; + // Skip files that would blow up memory; fall back to activity log + if (fileSize <= MAX_JSONL_BYTES * 2) { + const raw = readFileSync(sessionFile, "utf-8"); + const allEntries = parseJSONL(raw); + const sessionEntries = extractLastSession(allEntries); + trace = extractTrace(sessionEntries); + } } } @@ -452,7 +460,16 @@ function readLastActivityLog(activityDir?: string): ExecutionTrace | null { if (files.length === 0) return null; const lastFile = files[files.length - 1]!; - const raw = readFileSync(join(activityDir, lastFile), "utf-8"); + const filePath = join(activityDir, lastFile); + + // Try native JSONL parser first + const nativeResult = nativeParseJsonlTail(filePath, MAX_JSONL_BYTES); + if (nativeResult) { + return extractTrace(nativeResult.entries); + } + + // Fall back to JS parsing + const raw = readFileSync(filePath, "utf-8"); return extractTrace(parseJSONL(raw)); } catch { return null; diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 0cc4b6bc5..7818c75d9 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -134,45 +134,8 @@ async function _deriveStateImpl(basePath: string): Promise { const batchFiles = nativeBatchParseGsdFiles(gsdDir); if (batchFiles) { for (const f of batchFiles) { - // Reconstruct the full file content from parsed components so downstream - // parsers (parseRoadmap, parseSummary, etc.) receive the same input they - // expect from loadFile(). Files with frontmatter get it re-serialized; - // files without get just the body. const absPath = resolve(gsdDir, f.path); - const hasMetadata = Object.keys(f.metadata).length > 0; - if (hasMetadata) { - // Re-serialize frontmatter as simple YAML key: value lines - const fmLines: string[] = ['---']; - for (const [key, value] of Object.entries(f.metadata)) { - if (Array.isArray(value)) { - if (value.length === 0) { - fmLines.push(`${key}: []`); - } else if (typeof value[0] === 'object' && value[0] !== null) { - fmLines.push(`${key}:`); - for (const obj of value) { - const entries = Object.entries(obj as Record); - if (entries.length > 0) { - fmLines.push(` - ${entries[0][0]}: ${entries[0][1]}`); - for (let i = 1; i < entries.length; i++) { - fmLines.push(` ${entries[i][0]}: ${entries[i][1]}`); - } - } - } - } else { - fmLines.push(`${key}:`); - for (const item of value) { - fmLines.push(` - ${item}`); - } - } - } else { - fmLines.push(`${key}: ${value}`); - } - } - fmLines.push('---'); - fileContentCache.set(absPath, fmLines.join('\n') + '\n\n' + f.body); - } else { - fileContentCache.set(absPath, f.body); - } + fileContentCache.set(absPath, f.rawContent); } } diff --git a/src/resources/extensions/gsd/templates/plan.md b/src/resources/extensions/gsd/templates/plan.md index a8d154448..bc2ad6025 100644 --- a/src/resources/extensions/gsd/templates/plan.md +++ b/src/resources/extensions/gsd/templates/plan.md @@ -105,13 +105,6 @@ - Do: {{specificImplementationStepsAndConstraints}} - Verify: {{testCommandOrRuntimeCheck}} - Done when: {{measurableAcceptanceCondition}} -- [ ] **T03: {{taskTitle}}** `est:{{estimate}}` - - Why: {{whyThisTaskExists}} - - Files: `{{filePath}}`, `{{filePath}}` - - Do: {{specificImplementationStepsAndConstraints}} - - Verify: {{testCommandOrRuntimeCheck}} - - Done when: {{measurableAcceptanceCondition}} - + {{placeholder}} diff --git a/src/resources/extensions/gsd/templates/research.md b/src/resources/extensions/gsd/templates/research.md index 8f0d65816..fb63e757e 100644 --- a/src/resources/extensions/gsd/templates/research.md +++ b/src/resources/extensions/gsd/templates/research.md @@ -2,6 +2,11 @@ **Date:** {{date}} + + ## Summary {{summary — 2-3 paragraphs with primary recommendation}} @@ -10,37 +15,65 @@ {{whatApproachToTake_AND_why}} +## Implementation Landscape + + + +### Key Files + +- `{{filePath}}` — {{whatItDoesAndHowItRelates}} +- `{{filePath}}` — {{whatNeedsToChange}} + +### Build Order + +{{whatToProveOrBuildFirst_AND_why — whatUnblocksDownstreamWork}} + +### Verification Approach + +{{howToConfirmTheSliceWorks — commands, tests, observable behaviors}} + + + ## 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/state.md b/src/resources/extensions/gsd/templates/state.md index 76ce20ee7..131f1e843 100644 --- a/src/resources/extensions/gsd/templates/state.md +++ b/src/resources/extensions/gsd/templates/state.md @@ -4,7 +4,6 @@ **Active Slice:** {{sliceId}}: {{sliceTitle}} **Active Task:** {{taskId}}: {{taskTitle}} **Phase:** {{phase}} -**Active Workspace:** {{activeWorkspace}} **Next Action:** {{nextAction}} **Last Updated:** {{date}} **Requirements Status:** {{activeCount}} active · {{validatedCount}} validated · {{deferredCount}} deferred · {{outOfScopeCount}} out of scope diff --git a/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts b/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts new file mode 100644 index 000000000..5be2aa498 --- /dev/null +++ b/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { pauseAutoForProviderError } from "../provider-error-pause.ts"; + +test("pauseAutoForProviderError warns and pauses without requiring ctx.log", async () => { + const notifications: Array<{ message: string; level: string }> = []; + let pauseCalls = 0; + + await pauseAutoForProviderError( + { + notify(message, level?) { + notifications.push({ message, level: level ?? "info" }); + }, + }, + ": terminated", + async () => { + pauseCalls += 1; + }, + ); + + assert.equal(pauseCalls, 1, "should pause auto-mode exactly once"); + assert.deepEqual(notifications, [ + { + message: "Auto-mode paused due to provider error: terminated", + level: "warning", + }, + ]); +}); diff --git a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts new file mode 100644 index 000000000..614ecc8a3 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + unitVerb, + unitPhaseLabel, + describeNextUnit, + formatAutoElapsed, + formatWidgetTokens, +} from "../auto-dashboard.ts"; + +// ─── unitVerb ───────────────────────────────────────────────────────────── + +test("unitVerb maps known unit types to verbs", () => { + assert.equal(unitVerb("research-milestone"), "researching"); + assert.equal(unitVerb("research-slice"), "researching"); + assert.equal(unitVerb("plan-milestone"), "planning"); + assert.equal(unitVerb("plan-slice"), "planning"); + assert.equal(unitVerb("execute-task"), "executing"); + assert.equal(unitVerb("complete-slice"), "completing"); + assert.equal(unitVerb("replan-slice"), "replanning"); + assert.equal(unitVerb("reassess-roadmap"), "reassessing"); + assert.equal(unitVerb("run-uat"), "running UAT"); +}); + +test("unitVerb returns raw type for unknown types", () => { + assert.equal(unitVerb("custom-thing"), "custom-thing"); +}); + +test("unitVerb handles hook types", () => { + assert.equal(unitVerb("hook/verify-code"), "hook: verify-code"); + assert.equal(unitVerb("hook/"), "hook: "); +}); + +// ─── unitPhaseLabel ─────────────────────────────────────────────────────── + +test("unitPhaseLabel maps known types to labels", () => { + assert.equal(unitPhaseLabel("research-milestone"), "RESEARCH"); + assert.equal(unitPhaseLabel("research-slice"), "RESEARCH"); + assert.equal(unitPhaseLabel("plan-milestone"), "PLAN"); + assert.equal(unitPhaseLabel("plan-slice"), "PLAN"); + assert.equal(unitPhaseLabel("execute-task"), "EXECUTE"); + assert.equal(unitPhaseLabel("complete-slice"), "COMPLETE"); + assert.equal(unitPhaseLabel("replan-slice"), "REPLAN"); + assert.equal(unitPhaseLabel("reassess-roadmap"), "REASSESS"); + assert.equal(unitPhaseLabel("run-uat"), "UAT"); +}); + +test("unitPhaseLabel uppercases unknown types", () => { + assert.equal(unitPhaseLabel("custom-thing"), "CUSTOM-THING"); +}); + +test("unitPhaseLabel returns HOOK for hook types", () => { + assert.equal(unitPhaseLabel("hook/verify"), "HOOK"); +}); + +// ─── describeNextUnit ───────────────────────────────────────────────────── + +test("describeNextUnit handles pre-planning phase", () => { + const result = describeNextUnit({ + phase: "pre-planning", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.equal(result.label, "Research & plan milestone"); +}); + +test("describeNextUnit handles executing phase", () => { + const result = describeNextUnit({ + phase: "executing", + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: { id: "S01", title: "Slice" }, + activeTask: { id: "T01", title: "Task One" }, + } as any); + assert.ok(result.label.includes("T01")); + assert.ok(result.label.includes("Task One")); +}); + +test("describeNextUnit handles summarizing phase", () => { + const result = describeNextUnit({ + phase: "summarizing", + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: { id: "S01", title: "First Slice" }, + } as any); + assert.ok(result.label.includes("S01")); +}); + +test("describeNextUnit handles needs-discussion phase", () => { + const result = describeNextUnit({ + phase: "needs-discussion", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.ok( + result.label.toLowerCase().includes("discuss") || result.label.toLowerCase().includes("draft"), + ); +}); + +test("describeNextUnit handles completing-milestone phase", () => { + const result = describeNextUnit({ + phase: "completing-milestone", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.ok(result.label.toLowerCase().includes("milestone")); +}); + +test("describeNextUnit returns fallback for unknown phase", () => { + const result = describeNextUnit({ + phase: "some-future-phase" as any, + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.equal(result.label, "Continue"); +}); + +// ─── formatAutoElapsed ──────────────────────────────────────────────────── + +test("formatAutoElapsed returns empty for zero startTime", () => { + assert.equal(formatAutoElapsed(0), ""); +}); + +test("formatAutoElapsed formats seconds", () => { + const result = formatAutoElapsed(Date.now() - 30_000); + assert.match(result, /^\d+s$/); +}); + +test("formatAutoElapsed formats minutes", () => { + const result = formatAutoElapsed(Date.now() - 180_000); // 3 min + assert.match(result, /^3m/); +}); + +test("formatAutoElapsed formats hours", () => { + const result = formatAutoElapsed(Date.now() - 3_700_000); // ~1h + assert.match(result, /^1h/); +}); + +// ─── formatWidgetTokens ────────────────────────────────────────────────── + +test("formatWidgetTokens formats small numbers directly", () => { + assert.equal(formatWidgetTokens(0), "0"); + assert.equal(formatWidgetTokens(500), "500"); + assert.equal(formatWidgetTokens(999), "999"); +}); + +test("formatWidgetTokens formats thousands with k", () => { + assert.equal(formatWidgetTokens(1000), "1.0k"); + assert.equal(formatWidgetTokens(5500), "5.5k"); + assert.equal(formatWidgetTokens(10000), "10k"); + assert.equal(formatWidgetTokens(99999), "100k"); +}); + +test("formatWidgetTokens formats millions with M", () => { + assert.equal(formatWidgetTokens(1_000_000), "1.0M"); + assert.equal(formatWidgetTokens(10_000_000), "10M"); + assert.equal(formatWidgetTokens(25_000_000), "25M"); +}); diff --git a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts index 13650a257..fc76aee5a 100644 --- a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts +++ b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts @@ -74,14 +74,8 @@ assert( `executing label should include task ID, got: "${exResult.label}"`, ); -// ─── Static verification: needs-discussion in dispatchNextUnit ────────────── +// ─── Static verification: needs-discussion in dispatch table ────────────── -const autoSource = readFileSync( - join(import.meta.dirname, "..", "auto.ts"), - "utf-8", -); - -// describeNextUnit was extracted to auto-dashboard.ts — check there for the case const dashboardSource = readFileSync( join(import.meta.dirname, "..", "auto-dashboard.ts"), "utf-8", @@ -91,16 +85,22 @@ const dashboardSource = readFileSync( const hasDescribeCase = dashboardSource.includes('case "needs-discussion"'); assert(hasDescribeCase, "auto-dashboard.ts describeNextUnit should have 'needs-discussion' case"); -// Check dispatchNextUnit has the branch -const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"'); -assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch"); +// Dispatch logic moved to auto-dispatch.ts — verify the rule exists there +const dispatchSource = readFileSync( + join(import.meta.dirname, "..", "auto-dispatch.ts"), + "utf-8", +); -// Check the dispatch branch calls stopAuto -const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"'); -const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600); +// Check dispatch table has a needs-discussion rule +const hasDispatchRule = dispatchSource.includes('"needs-discussion"'); +assert(hasDispatchRule, "auto-dispatch.ts should have 'needs-discussion' rule"); + +// Check the rule returns a stop action +const ruleIdx = dispatchSource.indexOf('"needs-discussion"'); +const nextChunk = dispatchSource.slice(ruleIdx, ruleIdx + 600); assert( - nextChunk.includes("stopAuto"), - "needs-discussion dispatch branch should call stopAuto", + nextChunk.includes('"stop"') || nextChunk.includes("action: \"stop\""), + "needs-discussion dispatch rule should return stop action", ); // Check notification includes /gsd guidance diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts new file mode 100644 index 000000000..4ea508ac4 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -0,0 +1,322 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + resolveExpectedArtifactPath, + verifyExpectedArtifact, + diagnoseExpectedArtifact, + buildLoopRemediationSteps, + completedKeysPath, + persistCompletedKey, + removePersistedKey, + loadPersistedKeys, +} from "../auto-recovery.ts"; +import { parseRoadmap, clearParseCache } from "../files.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-test-${randomUUID()}`); + // Create .gsd/milestones/M001/slices/S01/tasks/ structure + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── resolveExpectedArtifactPath ────────────────────────────────────────── + +test("resolveExpectedArtifactPath returns correct path for research-milestone", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("research-milestone", "M001", base); + assert.ok(result); + assert.ok(result!.includes("M001")); + assert.ok(result!.includes("RESEARCH")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for execute-task", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("execute-task", "M001/S01/T01", base); + assert.ok(result); + assert.ok(result!.includes("tasks")); + assert.ok(result!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for complete-slice", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("complete-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for plan-slice", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("PLAN")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns null for unknown type", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("unknown-type", "M001", base); + assert.equal(result, null); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for all milestone-level types", () => { + const base = makeTmpBase(); + try { + const planResult = resolveExpectedArtifactPath("plan-milestone", "M001", base); + assert.ok(planResult); + assert.ok(planResult!.includes("ROADMAP")); + + const completeResult = resolveExpectedArtifactPath("complete-milestone", "M001", base); + assert.ok(completeResult); + assert.ok(completeResult!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for all slice-level types", () => { + const base = makeTmpBase(); + try { + const researchResult = resolveExpectedArtifactPath("research-slice", "M001/S01", base); + assert.ok(researchResult); + assert.ok(researchResult!.includes("RESEARCH")); + + const assessResult = resolveExpectedArtifactPath("reassess-roadmap", "M001/S01", base); + assert.ok(assessResult); + assert.ok(assessResult!.includes("ASSESSMENT")); + + const uatResult = resolveExpectedArtifactPath("run-uat", "M001/S01", base); + assert.ok(uatResult); + assert.ok(uatResult!.includes("UAT-RESULT")); + } finally { + cleanup(base); + } +}); + +// ─── diagnoseExpectedArtifact ───────────────────────────────────────────── + +test("diagnoseExpectedArtifact returns description for known types", () => { + const base = makeTmpBase(); + try { + const research = diagnoseExpectedArtifact("research-milestone", "M001", base); + assert.ok(research); + assert.ok(research!.includes("research")); + + const plan = diagnoseExpectedArtifact("plan-slice", "M001/S01", base); + assert.ok(plan); + assert.ok(plan!.includes("plan")); + + const task = diagnoseExpectedArtifact("execute-task", "M001/S01/T01", base); + assert.ok(task); + assert.ok(task!.includes("T01")); + } finally { + cleanup(base); + } +}); + +test("diagnoseExpectedArtifact returns null for unknown type", () => { + const base = makeTmpBase(); + try { + assert.equal(diagnoseExpectedArtifact("unknown", "M001", base), null); + } finally { + cleanup(base); + } +}); + +// ─── buildLoopRemediationSteps ──────────────────────────────────────────── + +test("buildLoopRemediationSteps returns steps for execute-task", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("execute-task", "M001/S01/T01", base); + assert.ok(steps); + assert.ok(steps!.includes("T01")); + assert.ok(steps!.includes("gsd doctor")); + assert.ok(steps!.includes("[x]")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns steps for plan-slice", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("plan-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("PLAN")); + assert.ok(steps!.includes("gsd doctor")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns steps for complete-slice", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("complete-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("S01")); + assert.ok(steps!.includes("ROADMAP")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns null for unknown type", () => { + const base = makeTmpBase(); + try { + assert.equal(buildLoopRemediationSteps("unknown", "M001", base), null); + } finally { + cleanup(base); + } +}); + +// ─── Completed-unit key persistence ─────────────────────────────────────── + +test("completedKeysPath returns path inside .gsd", () => { + const path = completedKeysPath("/project"); + assert.ok(path.includes(".gsd")); + assert.ok(path.includes("completed-units.json")); +}); + +test("persistCompletedKey and loadPersistedKeys round-trip", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "execute-task/M001/S01/T01"); + persistCompletedKey(base, "plan-slice/M001/S02"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + + assert.ok(keys.has("execute-task/M001/S01/T01")); + assert.ok(keys.has("plan-slice/M001/S02")); + assert.equal(keys.size, 2); + } finally { + cleanup(base); + } +}); + +test("persistCompletedKey is idempotent", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "execute-task/M001/S01/T01"); + persistCompletedKey(base, "execute-task/M001/S01/T01"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + assert.equal(keys.size, 1); + } finally { + cleanup(base); + } +}); + +test("removePersistedKey removes a key", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "a"); + persistCompletedKey(base, "b"); + removePersistedKey(base, "a"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + assert.ok(!keys.has("a")); + assert.ok(keys.has("b")); + } finally { + cleanup(base); + } +}); + +test("loadPersistedKeys handles missing file gracefully", () => { + const base = makeTmpBase(); + try { + const keys = new Set(); + assert.doesNotThrow(() => loadPersistedKeys(base, keys)); + assert.equal(keys.size, 0); + } finally { + cleanup(base); + } +}); + +test("removePersistedKey is safe when file doesn't exist", () => { + const base = makeTmpBase(); + try { + assert.doesNotThrow(() => removePersistedKey(base, "nonexistent")); + } finally { + cleanup(base); + } +}); + +// ─── verifyExpectedArtifact: parse cache collision regression ───────────── + +test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => { + // Regression test: cacheKey collision when [ ] → [x] doesn't change + // file length or first/last 100 chars. Without the fix, parseRoadmap + // returns stale cached data with done=false even though the file has [x]. + const base = makeTmpBase(); + try { + // Build a roadmap long enough that the [x] change is outside the first/last 100 chars + const padding = "A".repeat(200); + const roadmapBefore = [ + `# M001: Test Milestone ${padding}`, + "", + "## Slices", + "", + "- [ ] **S01: First slice** `risk:low`", + "", + `## Footer ${padding}`, + ].join("\n"); + const roadmapAfter = roadmapBefore.replace("- [ ] **S01:", "- [x] **S01:"); + + // Verify lengths are identical (the key collision condition) + assert.equal(roadmapBefore.length, roadmapAfter.length); + + // Populate parse cache with the pre-edit roadmap + const before = parseRoadmap(roadmapBefore); + const sliceBefore = before.slices.find(s => s.id === "S01"); + assert.ok(sliceBefore); + assert.equal(sliceBefore!.done, false); + + // Now write the post-edit roadmap to disk and create required artifacts + const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + writeFileSync(roadmapPath, roadmapAfter); + const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + writeFileSync(summaryPath, "# Summary\nDone."); + const uatPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"); + writeFileSync(uatPath, "# UAT\nPassed."); + + // verifyExpectedArtifact should see the [x] despite the parse cache + // having the [ ] version. The fix clears the parse cache inside verify. + const verified = verifyExpectedArtifact("complete-slice", "M001/S01", base); + assert.equal(verified, true, "verifyExpectedArtifact should return true when roadmap has [x]"); + } finally { + clearParseCache(); + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index af6e64e13..df78b49d8 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -247,6 +247,49 @@ async function main(): Promise { assertEq(result.pushed, false, "pushed is false without discoverable prefs"); } + // ─── Test 5: Auto-resolve .gsd/ state file conflicts (#530) ─────── + console.log("\n=== auto-resolve .gsd/ state file conflicts ==="); + { + const repo = freshRepo(); + const wtPath = createAutoWorktree(repo, "M050"); + + // Add a slice with real work + addSliceToMilestone(repo, wtPath, "M050", "S01", "Conflict test", [ + { file: "feature.ts", content: "export const feature = true;\n", message: "add feature" }, + ]); + + // Modify .gsd/STATE.md on the milestone branch (simulates auto-mode state updates) + writeFileSync(join(wtPath, ".gsd", "STATE.md"), "# State\n\n## Updated on milestone branch\n"); + run("git add .", wtPath); + run('git commit -m "chore: update state on milestone branch"', wtPath); + + // Now modify .gsd/STATE.md on main too (simulates divergence) + run("git checkout main", repo); + writeFileSync(join(repo, ".gsd", "STATE.md"), "# State\n\n## Updated on main\n"); + run("git add .", repo); + run('git commit -m "chore: update state on main"', repo); + + // Go back to worktree for the merge + process.chdir(wtPath); + + const roadmap = makeRoadmap("M050", "Conflict resolution", [ + { id: "S01", title: "Conflict test" }, + ]); + + // Merge should succeed despite .gsd/STATE.md conflict — auto-resolved + let threw = false; + try { + const result = mergeMilestoneToMain(repo, "M050", roadmap); + assertTrue(result.commitMessage.includes("feat(M050)"), "merge commit created despite .gsd conflict"); + } catch (err) { + threw = true; + } + assertTrue(!threw, "auto-resolves .gsd/ state file conflicts without throwing"); + + // Feature file should be on main + assertTrue(existsSync(join(repo, "feature.ts")), "feature.ts merged to main"); + } + } finally { process.chdir(savedCwd); for (const d of tempDirs) { diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index a1a832468..b6b4a4498 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -141,7 +141,7 @@ async function main(): Promise { } } - report("auto-worktree"); + report(); } main(); diff --git a/src/resources/extensions/gsd/tests/budget-prediction.test.ts b/src/resources/extensions/gsd/tests/budget-prediction.test.ts new file mode 100644 index 000000000..52c05a0a6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/budget-prediction.test.ts @@ -0,0 +1,220 @@ +/** + * Budget Prediction — unit tests for M004/S04. + * + * Tests prediction math, auto-downgrade logic, and dashboard integration. + * Uses extracted pure functions (avoiding module import chain) and + * source-level structural checks for dashboard/auto.ts integration. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const metricsSrc = readFileSync(join(__dirname, "..", "metrics.ts"), "utf-8"); +const dashboardSrc = readFileSync(join(__dirname, "..", "auto-dashboard.ts"), "utf-8"); + +// ─── Extract pure functions from metrics.ts source ──────────────────────── +// Can't import directly due to paths.js → @gsd/pi-coding-agent import chain. +// Extract and evaluate the pure math functions. + +interface MockUnitMetrics { + type: string; + cost: number; +} + +// Re-implement the functions under test (verified against source below) +function getAverageCostPerUnitType(units: MockUnitMetrics[]): Map { + const sums = new Map(); + for (const u of units) { + const entry = sums.get(u.type) ?? { total: 0, count: 0 }; + entry.total += u.cost; + entry.count += 1; + sums.set(u.type, entry); + } + const avgs = new Map(); + for (const [type, { total, count }] of sums) { + avgs.set(type, total / count); + } + return avgs; +} + +function predictRemainingCost( + avgCosts: Map, + remainingUnits: string[], + fallbackAvg?: number, +): number { + const allAvgs = [...avgCosts.values()]; + const overallAvg = fallbackAvg ?? (allAvgs.length > 0 ? allAvgs.reduce((a, b) => a + b, 0) / allAvgs.length : 0); + let total = 0; + for (const unitType of remainingUnits) { + total += avgCosts.get(unitType) ?? overallAvg; + } + return total; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Source Verification — confirm our re-implementation matches +// ═══════════════════════════════════════════════════════════════════════════ + +test("source: metrics.ts exports getAverageCostPerUnitType", () => { + assert.ok(metricsSrc.includes("export function getAverageCostPerUnitType"), "should be exported"); +}); + +test("source: metrics.ts exports predictRemainingCost", () => { + assert.ok(metricsSrc.includes("export function predictRemainingCost"), "should be exported"); +}); + +test("source: getAverageCostPerUnitType uses Map", () => { + assert.ok( + metricsSrc.includes("Map") && metricsSrc.includes("getAverageCostPerUnitType"), + "should return Map", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Average Cost Per Unit Type +// ═══════════════════════════════════════════════════════════════════════════ + +test("avgCost: returns correct averages per unit type", () => { + const units: MockUnitMetrics[] = [ + { type: "execute-task", cost: 0.10 }, + { type: "execute-task", cost: 0.20 }, + { type: "plan-slice", cost: 0.05 }, + { type: "plan-slice", cost: 0.15 }, + { type: "complete-slice", cost: 0.08 }, + ]; + const avgs = getAverageCostPerUnitType(units); + assert.ok(Math.abs(avgs.get("execute-task")! - 0.15) < 0.001, "execute-task avg should be 0.15"); + assert.ok(Math.abs(avgs.get("plan-slice")! - 0.10) < 0.001, "plan-slice avg should be 0.10"); + assert.ok(Math.abs(avgs.get("complete-slice")! - 0.08) < 0.001, "complete-slice avg should be 0.08"); +}); + +test("avgCost: returns empty map for empty input", () => { + const avgs = getAverageCostPerUnitType([]); + assert.equal(avgs.size, 0); +}); + +test("avgCost: single unit per type returns exact cost", () => { + const avgs = getAverageCostPerUnitType([{ type: "execute-task", cost: 0.42 }]); + assert.ok(Math.abs(avgs.get("execute-task")! - 0.42) < 0.001); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Predict Remaining Cost +// ═══════════════════════════════════════════════════════════════════════════ + +test("predict: calculates remaining cost from averages", () => { + const avgs = new Map([ + ["execute-task", 0.15], + ["plan-slice", 0.10], + ["complete-slice", 0.08], + ]); + const remaining = ["execute-task", "execute-task", "complete-slice"]; + const cost = predictRemainingCost(avgs, remaining); + assert.ok(Math.abs(cost - 0.38) < 0.001); +}); + +test("predict: uses overall average for unknown unit types", () => { + const avgs = new Map([ + ["execute-task", 0.10], + ["plan-slice", 0.20], + ]); + const remaining = ["execute-task", "unknown-type"]; + const cost = predictRemainingCost(avgs, remaining); + // unknown: (0.10 + 0.20) / 2 = 0.15 → total 0.10 + 0.15 = 0.25 + assert.ok(Math.abs(cost - 0.25) < 0.001); +}); + +test("predict: returns 0 for empty remaining", () => { + const avgs = new Map([["execute-task", 0.15]]); + assert.equal(predictRemainingCost(avgs, []), 0); +}); + +test("predict: handles no averages with fallback", () => { + const avgs = new Map(); + const cost = predictRemainingCost(avgs, ["execute-task", "plan-slice"], 0.10); + assert.ok(Math.abs(cost - 0.20) < 0.001); +}); + +test("predict: handles no averages and no fallback", () => { + const avgs = new Map(); + const cost = predictRemainingCost(avgs, ["execute-task"]); + assert.equal(cost, 0); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Dashboard Integration +// ═══════════════════════════════════════════════════════════════════════════ + +test("dashboard: AutoDashboardData includes projectedRemainingCost field", () => { + assert.ok( + dashboardSrc.includes("projectedRemainingCost"), + "AutoDashboardData should have projectedRemainingCost field", + ); +}); + +test("dashboard: AutoDashboardData includes profileDowngraded field", () => { + assert.ok( + dashboardSrc.includes("profileDowngraded"), + "AutoDashboardData should have profileDowngraded field", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Budget Prediction — End-to-End Math +// ═══════════════════════════════════════════════════════════════════════════ + +test("e2e: budget ceiling exceeded triggers downgrade prediction", () => { + const units: MockUnitMetrics[] = [ + { type: "execute-task", cost: 0.50 }, + { type: "execute-task", cost: 0.60 }, + { type: "plan-slice", cost: 0.30 }, + { type: "complete-slice", cost: 0.20 }, + ]; + const totalSpent = units.reduce((sum, u) => sum + u.cost, 0); // 1.60 + const avgs = getAverageCostPerUnitType(units); + const remaining = ["execute-task", "execute-task", "execute-task"]; + const predictedRemaining = predictRemainingCost(avgs, remaining); + const predictedTotal = totalSpent + predictedRemaining; + const budgetCeiling = 2.50; + assert.ok(predictedTotal > budgetCeiling, "should predict budget exhaustion"); +}); + +test("e2e: budget ceiling not exceeded does not trigger", () => { + const units: MockUnitMetrics[] = [ + { type: "execute-task", cost: 0.10 }, + { type: "plan-slice", cost: 0.05 }, + ]; + const totalSpent = units.reduce((sum, u) => sum + u.cost, 0); // 0.15 + const avgs = getAverageCostPerUnitType(units); + const remaining = ["execute-task", "complete-slice"]; + const predictedRemaining = predictRemainingCost(avgs, remaining); + const predictedTotal = totalSpent + predictedRemaining; + const budgetCeiling = 5.00; + assert.ok(predictedTotal <= budgetCeiling, "should not predict budget exhaustion"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Downgrade Logic +// ═══════════════════════════════════════════════════════════════════════════ + +test("downgrade: one-way per D048 — downgrade should not be reversible", () => { + // Simulate: first prediction triggers downgrade, second doesn't reverse it + let downgraded = false; + + function checkDowngrade(predictedTotal: number, ceiling: number) { + if (!downgraded && predictedTotal > ceiling) { + downgraded = true; + } + // Never reverse — per D048 + } + + checkDowngrade(3.00, 2.50); // triggers + assert.ok(downgraded, "should downgrade when prediction exceeds ceiling"); + + checkDowngrade(1.50, 2.50); // doesn't reverse + assert.ok(downgraded, "should stay downgraded (one-way per D048)"); +}); diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index af7389701..cb1a7124a 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -3,8 +3,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { createTestContext } from './test-helpers.ts'; -import { clearPathCache } from '../paths.ts'; -import { invalidateStateCache } from '../state.ts'; +import { invalidateAllCaches } from '../cache.ts'; // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts @@ -63,6 +62,7 @@ async function main(): Promise { let threw = false; try { result = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", milestoneTitle: "Test Milestone", roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", @@ -82,6 +82,7 @@ async function main(): Promise { console.log("\n=== prompt variable substitution ==="); { const prompt = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", milestoneTitle: "Integration Feature", roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", @@ -102,6 +103,7 @@ async function main(): Promise { console.log("\n=== prompt content integrity ==="); { const prompt = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M002", milestoneTitle: "Completion Workflow", roadmapPath: ".gsd/milestones/M002/M002-ROADMAP.md", @@ -148,7 +150,8 @@ async function main(): Promise { // ─── deriveState integration: completing-milestone dispatches correctly ─ console.log("\n=== deriveState completing-milestone integration ==="); { - const { deriveState, isMilestoneComplete, invalidateStateCache } = await import("../state.ts"); + const { deriveState, isMilestoneComplete } = await import("../state.ts"); + const { invalidateAllCaches: invalidateAllCachesDynamic } = await import("../cache.ts"); const { parseRoadmap } = await import("../files.ts"); const base = createFixtureBase(); @@ -181,8 +184,7 @@ async function main(): Promise { // Now add the summary and verify it transitions to complete writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); - clearPathCache(); - invalidateStateCache(); + invalidateAllCachesDynamic(); 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"); diff --git a/src/resources/extensions/gsd/tests/complexity-routing.test.ts b/src/resources/extensions/gsd/tests/complexity-routing.test.ts new file mode 100644 index 000000000..634012cd5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/complexity-routing.test.ts @@ -0,0 +1,294 @@ +/** + * Complexity Routing — unit tests for M004/S03. + * + * Tests task complexity classification accuracy and dispatch integration. + * Uses direct imports for the classifier (pure function, no heavy deps) + * and source-level checks for dispatch/preference wiring. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { classifyTaskComplexity } from "../complexity.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const preferencesSrc = readFileSync(join(__dirname, "..", "preferences.ts"), "utf-8"); +const complexitySrc = readFileSync(join(__dirname, "..", "complexity.ts"), "utf-8"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Classification: Simple Tasks +// ═══════════════════════════════════════════════════════════════════════════ + +test("classify: minimal task plan (2 steps, 1 file) → simple", () => { + const plan = `# T01: Add config key + +## Steps +1. Add key to interface +2. Update validation + +## Files +- \`config.ts\` +`; + assert.equal(classifyTaskComplexity(plan), "simple"); +}); + +test("classify: 3 steps, 2 files, short description → simple", () => { + const plan = `# T01: Update types + +Short description. + +## Steps +1. Add type +2. Export it +3. Update imports + +## Files +- \`types.ts\` +- \`index.ts\` +`; + assert.equal(classifyTaskComplexity(plan), "simple"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Classification: Standard Tasks +// ═══════════════════════════════════════════════════════════════════════════ + +test("classify: medium task plan (5 steps, 4 files) → standard", () => { + const plan = `# T02: Implement auth middleware + +Add JWT verification middleware. + +## Steps +1. Create middleware file +2. Add token verification +3. Wire into router +4. Add error handling +5. Update types + +## Files +- \`middleware.ts\` +- \`auth.ts\` +- \`router.ts\` +- \`types.ts\` +`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +test("classify: 3 steps but complexity signal word → standard (not simple)", () => { + const plan = `# T01: Refactor auth + +## Steps +1. Extract helper +2. Update callers +3. Test + +## Files +- \`auth.ts\` +`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +test("classify: 4 steps, short but 4 files → standard", () => { + const plan = `# T01: Wire up + +Short. + +## Steps +1. Step one +2. Step two +3. Step three +4. Step four + +## Files +- \`a.ts\` +- \`b.ts\` +- \`c.ts\` +- \`d.ts\` +`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Classification: Complex Tasks +// ═══════════════════════════════════════════════════════════════════════════ + +test("classify: large task plan (10 steps, 8 files) → complex", () => { + const plan = `# T03: Migrate database schema + +Full database migration with backward compatibility. + +## Steps +1. Create migration file +2. Add new columns +3. Migrate existing data +4. Update ORM models +5. Update API handlers +6. Update tests +7. Run migration locally +8. Verify rollback +9. Update docs +10. Deploy staging + +## Files +- \`migrations/001.ts\` +- \`models/user.ts\` +- \`models/session.ts\` +- \`api/users.ts\` +- \`api/sessions.ts\` +- \`tests/user.test.ts\` +- \`tests/session.test.ts\` +- \`docs/schema.md\` +`; + assert.equal(classifyTaskComplexity(plan), "complex"); +}); + +test("classify: long description (>2000 chars) → complex", () => { + const longDesc = "A".repeat(2100); + const plan = `# T01: Complex task + +${longDesc} + +## Steps + +1. Do it +2. Done +`; + assert.equal(classifyTaskComplexity(plan), "complex"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Classification: Edge Cases +// ═══════════════════════════════════════════════════════════════════════════ + +test("classify: empty plan → standard (conservative default)", () => { + assert.equal(classifyTaskComplexity(""), "standard"); +}); + +test("classify: plan with no Steps section → standard", () => { + const plan = `# T01: Something\n\nJust a description with no structure.\n`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +test("classify: null-ish input → standard", () => { + assert.equal(classifyTaskComplexity(" "), "standard"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Complexity Signal Words +// ═══════════════════════════════════════════════════════════════════════════ + +test("classify: 'investigate' signal prevents simple classification", () => { + const plan = `# T01: Investigate auth bug\n\n## Steps\n1. Check logs\n2. Fix\n`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +test("classify: 'security' signal prevents simple classification", () => { + const plan = `# T01: Security audit\n\n## Steps\n1. Review\n2. Fix\n`; + assert.equal(classifyTaskComplexity(plan), "standard"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Model Config — execution_simple +// ═══════════════════════════════════════════════════════════════════════════ + +test("preferences: GSDModelConfig includes execution_simple field", () => { + const v1Match = preferencesSrc.match(/interface GSDModelConfig\s*\{[^}]*execution_simple/); + assert.ok(v1Match, "GSDModelConfig should have execution_simple field"); + const v2Match = preferencesSrc.match(/interface GSDModelConfigV2\s*\{[^}]*execution_simple/); + assert.ok(v2Match, "GSDModelConfigV2 should have execution_simple field"); +}); + +test("preferences: budget profile sets execution_simple model", () => { + const budgetIdx = preferencesSrc.indexOf('case "budget":'); + const balancedIdx = preferencesSrc.indexOf('case "balanced":'); + const budgetBlock = preferencesSrc.slice(budgetIdx, balancedIdx); + assert.ok(budgetBlock.includes("execution_simple:"), "budget profile should set execution_simple"); +}); + +test("preferences: resolveModelWithFallbacksForUnit handles execute-task-simple", () => { + assert.ok( + preferencesSrc.includes('"execute-task-simple"'), + "should have execute-task-simple case in model resolution", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Classifier Module Structure +// ═══════════════════════════════════════════════════════════════════════════ + +test("complexity: module exports classifyTaskComplexity function", () => { + assert.ok( + complexitySrc.includes("export function classifyTaskComplexity"), + "should export classifyTaskComplexity", + ); +}); + +test("complexity: module exports TaskComplexity type", () => { + assert.ok( + complexitySrc.includes("export type TaskComplexity"), + "should export TaskComplexity type", + ); +}); + +test("complexity: classifier uses conservative defaults", () => { + // Verify empty/missing input returns standard + assert.ok( + complexitySrc.includes('return "standard"'), + "should have standard as default return", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Unit Complexity Classification (from #579 — combined) +// ═══════════════════════════════════════════════════════════════════════════ + +const complexitySrcFull = readFileSync(join(__dirname, "..", "complexity.ts"), "utf-8"); + +test("unit-classify: classifyUnitComplexity is exported", () => { + assert.ok( + complexitySrcFull.includes("export function classifyUnitComplexity"), + "should export classifyUnitComplexity", + ); +}); + +test("unit-classify: unit type tier mapping exists", () => { + assert.ok(complexitySrcFull.includes("UNIT_TYPE_TIERS"), "should have unit type tier mapping"); + assert.ok(complexitySrcFull.includes('"complete-slice": "light"'), "complete-slice should be light"); + assert.ok(complexitySrcFull.includes('"replan-slice": "heavy"'), "replan-slice should be heavy"); +}); + +test("unit-classify: hook units default to light", () => { + assert.ok( + complexitySrcFull.includes('startsWith("hook/")') && complexitySrcFull.includes('"light"'), + "hook units should default to light tier", + ); +}); + +test("unit-classify: budget pressure has graduated thresholds", () => { + assert.ok(complexitySrcFull.includes("budgetPct >= 0.9"), "should have 90% threshold"); + assert.ok(complexitySrcFull.includes("budgetPct >= 0.75"), "should have 75% threshold"); + assert.ok(complexitySrcFull.includes("budgetPct < 0.5"), "should skip below 50%"); +}); + +test("unit-classify: escalateTier function exists", () => { + assert.ok( + complexitySrcFull.includes("export function escalateTier"), + "should export escalateTier for failure recovery", + ); +}); + +test("unit-classify: tierLabel function exists", () => { + assert.ok( + complexitySrcFull.includes("export function tierLabel"), + "should export tierLabel for dashboard display", + ); +}); + +test("unit-classify: ComplexityTier imported from types.ts", () => { + assert.ok( + complexitySrcFull.includes('from "./types.js"') && complexitySrcFull.includes("ComplexityTier"), + "should import ComplexityTier from types", + ); +}); diff --git a/src/resources/extensions/gsd/tests/context-compression.test.ts b/src/resources/extensions/gsd/tests/context-compression.test.ts new file mode 100644 index 000000000..3b9e649f5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/context-compression.test.ts @@ -0,0 +1,180 @@ +/** + * Context Compression — unit tests for M004/S02. + * + * Verifies that prompt builders respect inlineLevel parameter by + * inspecting the auto-prompts.ts source for level-aware gating. + * Cannot call builders directly due to @gsd/pi-coding-agent import + * resolution — uses source-level structural verification instead. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8"); + +// ═══════════════════════════════════════════════════════════════════════════ +// inlineLevel Parameter Presence +// ═══════════════════════════════════════════════════════════════════════════ + +const BUILDERS_WITH_LEVEL = [ + "buildPlanMilestonePrompt", + "buildPlanSlicePrompt", + "buildExecuteTaskPrompt", + "buildCompleteSlicePrompt", + "buildCompleteMilestonePrompt", + "buildReassessRoadmapPrompt", +]; + +for (const builder of BUILDERS_WITH_LEVEL) { + test(`compression: ${builder} accepts inlineLevel parameter`, () => { + // Find the function signature + const sigRegex = new RegExp(`export async function ${builder}\\([^)]*level\\?: InlineLevel`); + assert.ok( + sigRegex.test(promptsSrc), + `${builder} should have level?: InlineLevel parameter`, + ); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Default Level Resolution +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: builders default to resolveInlineLevel() when no level passed", () => { + const defaultPattern = /const inlineLevel = level \?\? resolveInlineLevel\(\)/g; + const matches = promptsSrc.match(defaultPattern); + assert.ok(matches, "should have resolveInlineLevel() fallback"); + assert.ok( + matches.length >= BUILDERS_WITH_LEVEL.length, + `should have ${BUILDERS_WITH_LEVEL.length} fallback instances, found ${matches?.length}`, + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Minimal Level — Template Reduction +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: buildExecuteTaskPrompt minimal drops decisions template", () => { + // In the execute-task builder, minimal should only inline task-summary, not decisions + assert.ok( + promptsSrc.includes('inlineLevel === "minimal"') && + promptsSrc.includes('inlineTemplate("task-summary"'), + "execute-task should conditionally include decisions template based on level", + ); +}); + +test("compression: buildExecuteTaskPrompt minimal truncates prior summaries", () => { + assert.ok( + promptsSrc.includes('inlineLevel === "minimal" && priorSummaries.length > 1'), + "execute-task should limit prior summaries for minimal level", + ); +}); + +test("compression: buildPlanMilestonePrompt minimal drops project/requirements/decisions files", () => { + // The plan-milestone builder should gate root file inlining on inlineLevel + assert.ok( + promptsSrc.includes('inlineLevel !== "minimal"') && + promptsSrc.includes('inlineGsdRootFile(base, "project.md"'), + "plan-milestone should conditionally include project.md based on level", + ); +}); + +test("compression: buildPlanMilestonePrompt minimal drops extra templates", () => { + // Full inlines 5 templates, minimal should inline fewer + assert.ok( + promptsSrc.includes('if (inlineLevel === "full")') && + promptsSrc.includes('inlineTemplate("secrets-manifest"'), + "plan-milestone should only include secrets-manifest template at full level", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Complete-Slice Level Gating +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: buildCompleteSlicePrompt minimal drops requirements", () => { + // Find the complete-slice section and verify requirements gating + const completeSliceIdx = promptsSrc.indexOf("buildCompleteSlicePrompt"); + const nextBuilder = promptsSrc.indexOf("buildCompleteMilestonePrompt"); + const completeSliceBlock = promptsSrc.slice(completeSliceIdx, nextBuilder); + assert.ok( + completeSliceBlock.includes('inlineLevel !== "minimal"'), + "complete-slice should gate requirements inlining on level", + ); +}); + +test("compression: buildCompleteSlicePrompt minimal drops UAT template", () => { + const completeSliceIdx = promptsSrc.indexOf("buildCompleteSlicePrompt"); + const nextBuilder = promptsSrc.indexOf("buildCompleteMilestonePrompt"); + const completeSliceBlock = promptsSrc.slice(completeSliceIdx, nextBuilder); + assert.ok( + completeSliceBlock.includes('inlineLevel !== "minimal"') && + completeSliceBlock.includes('inlineTemplate("uat"'), + "complete-slice should conditionally include UAT template based on level", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Complete-Milestone Level Gating +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: buildCompleteMilestonePrompt minimal drops root GSD files", () => { + const completeMilestoneIdx = promptsSrc.indexOf("buildCompleteMilestonePrompt"); + const nextBuilder = promptsSrc.indexOf("buildReplanSlicePrompt"); + const block = promptsSrc.slice(completeMilestoneIdx, nextBuilder); + assert.ok( + block.includes('inlineLevel !== "minimal"') && + block.includes('inlineGsdRootFile(base, "requirements.md"'), + "complete-milestone should gate root file inlining on level", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Reassess-Roadmap Level Gating +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: buildReassessRoadmapPrompt minimal drops project/requirements/decisions", () => { + const reassessIdx = promptsSrc.indexOf("buildReassessRoadmapPrompt"); + const block = promptsSrc.slice(reassessIdx, reassessIdx + 1500); + assert.ok( + block.includes('inlineLevel !== "minimal"'), + "reassess-roadmap should gate file inlining on level", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Full Level — No Regression +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: full level preserves all templates and files (no regression)", () => { + // Verify the key template names are still present in the source + const expectedTemplates = [ + "roadmap", "decisions", "plan", "task-plan", "secrets-manifest", + "task-summary", "slice-summary", "uat", "milestone-summary", + ]; + for (const tpl of expectedTemplates) { + assert.ok( + promptsSrc.includes(`inlineTemplate("${tpl}"`), + `template "${tpl}" should still be present in auto-prompts.ts`, + ); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Import Verification +// ═══════════════════════════════════════════════════════════════════════════ + +test("compression: auto-prompts.ts imports resolveInlineLevel and InlineLevel", () => { + assert.ok( + promptsSrc.includes("resolveInlineLevel"), + "should import resolveInlineLevel from preferences", + ); + assert.ok( + promptsSrc.includes("InlineLevel"), + "should import InlineLevel type from types", + ); +}); diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts new file mode 100644 index 000000000..bce69cc7a --- /dev/null +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + writeLock, + clearLock, + readCrashLock, + isLockProcessAlive, + formatCrashInfo, + type LockData, +} from "../crash-recovery.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── writeLock / readCrashLock ──────────────────────────────────────────── + +test("writeLock creates lock file and readCrashLock reads it", () => { + const base = makeTmpBase(); + try { + writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl"); + const lock = readCrashLock(base); + assert.ok(lock, "lock should exist"); + assert.equal(lock!.unitType, "execute-task"); + assert.equal(lock!.unitId, "M001/S01/T01"); + assert.equal(lock!.completedUnits, 3); + assert.equal(lock!.sessionFile, "/tmp/session.jsonl"); + assert.equal(lock!.pid, process.pid); + } finally { + cleanup(base); + } +}); + +test("readCrashLock returns null when no lock exists", () => { + const base = makeTmpBase(); + try { + const lock = readCrashLock(base); + assert.equal(lock, null); + } finally { + cleanup(base); + } +}); + +// ─── clearLock ──────────────────────────────────────────────────────────── + +test("clearLock removes existing lock file", () => { + const base = makeTmpBase(); + try { + writeLock(base, "plan-slice", "M001/S01", 0); + assert.ok(readCrashLock(base), "lock should exist before clear"); + clearLock(base); + assert.equal(readCrashLock(base), null, "lock should be gone after clear"); + } finally { + cleanup(base); + } +}); + +test("clearLock is safe when no lock exists", () => { + const base = makeTmpBase(); + try { + assert.doesNotThrow(() => clearLock(base)); + } finally { + cleanup(base); + } +}); + +// ─── isLockProcessAlive ────────────────────────────────────────────────── + +test("isLockProcessAlive returns true for current process (different pid)", () => { + // Our own PID is explicitly excluded (recycled PID guard) + const lock: LockData = { + pid: process.pid, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false, "own PID should return false"); +}); + +test("isLockProcessAlive returns false for dead PID", () => { + const lock: LockData = { + pid: 999999999, // almost certainly not running + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false); +}); + +test("isLockProcessAlive returns false for invalid PIDs", () => { + const base: Omit = { + startedAt: new Date().toISOString(), + unitType: "x", + unitId: "x", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive({ ...base, pid: 0 } as LockData), false); + assert.equal(isLockProcessAlive({ ...base, pid: -1 } as LockData), false); + assert.equal(isLockProcessAlive({ ...base, pid: 1.5 } as LockData), false); +}); + +// ─── formatCrashInfo ───────────────────────────────────────────────────── + +test("formatCrashInfo includes unit type, id, and PID", () => { + const lock: LockData = { + pid: 12345, + startedAt: "2025-01-01T00:00:00.000Z", + unitType: "complete-slice", + unitId: "M002/S03", + unitStartedAt: "2025-01-01T00:01:00.000Z", + completedUnits: 7, + }; + const info = formatCrashInfo(lock); + assert.ok(info.includes("complete-slice")); + assert.ok(info.includes("M002/S03")); + assert.ok(info.includes("12345")); + assert.ok(info.includes("7")); +}); diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 2e84b6cca..eb5dc8da5 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -1,14 +1,13 @@ +// GSD Dispatch Guard Tests +// Copyright (c) 2026 Jeremy McSpadden + import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { getPriorSliceCompletionBlocker } from "../dispatch-guard.ts"; import { createTestContext } from './test-helpers.ts'; -const { assertEq, report } = createTestContext(); -function run(command: string, cwd: string): void { - execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"] }); -} +const { assertEq, assertTrue, report } = createTestContext(); const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); try { @@ -33,18 +32,14 @@ try { "", ].join("\n")); - run("git init -b main", repo); - run("git config user.email test@example.com", repo); - run("git config user.name Test", repo); - run("git add .", repo); - run("git commit -m init", repo); - + // dispatch-guard now reads from disk, not git — no need for git init/commit assertEq( getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), - "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete on main.", - "blocks first slice of next milestone when prior milestone is incomplete on main", + "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", + "blocks first slice of next milestone when prior milestone is incomplete", ); + // Complete M002 on disk writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), [ "# M002: Previous", "", @@ -53,15 +48,14 @@ try { "- [x] **S02: Done** `risk:low` `depends:[S01]`", "", ].join("\n")); - run("git add .", repo); - run("git commit -m complete-m002", repo); assertEq( getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), - "Cannot dispatch execute-task M003/S02/T01: earlier slice M003/S01 is not complete on main.", - "blocks later slice in same milestone when an earlier slice is incomplete on main", + "Cannot dispatch execute-task M003/S02/T01: earlier slice M003/S01 is not complete.", + "blocks later slice in same milestone when an earlier slice is incomplete", ); + // Complete M003/S01 on disk writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), [ "# M003: Current", "", @@ -70,13 +64,11 @@ try { "- [ ] **S02: Second** `risk:low` `depends:[S01]`", "", ].join("\n")); - run("git add .", repo); - run("git commit -m complete-m003-s01", repo); assertEq( getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null, - "allows dispatch when all earlier slices are complete on main", + "allows dispatch when all earlier slices are complete on disk", ); assertEq( @@ -84,6 +76,28 @@ try { null, "does not affect non-slice dispatch types", ); + + // Verify disk-based reads work without any git repo (#530) + const noGitRepo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-nogit-")); + try { + mkdirSync(join(noGitRepo, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(noGitRepo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ + "# M001: Test", + "", + "## Slices", + "- [x] **S01: Done** `risk:low` `depends:[]`", + "- [ ] **S02: Pending** `risk:low` `depends:[S01]`", + "", + ].join("\n")); + + assertEq( + getPriorSliceCompletionBlocker(noGitRepo, "main", "plan-slice", "M001/S02"), + null, + "allows dispatch for S02 when S01 is complete (no git repo needed)", + ); + } finally { + rmSync(noGitRepo, { recursive: true, force: true }); + } } finally { rmSync(repo, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/tests/draft-promotion.test.ts b/src/resources/extensions/gsd/tests/draft-promotion.test.ts index 0ce24ed50..aaad0e2da 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -2,8 +2,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node: import { join } from "node:path"; import { tmpdir } from "node:os"; -import { deriveState, invalidateStateCache } from "../state.js"; -import { resolveMilestoneFile, clearPathCache } from "../paths.js"; +import { deriveState } from "../state.js"; +import { resolveMilestoneFile } from "../paths.js"; +import { invalidateAllCaches } from "../cache.js"; let passed = 0; let failed = 0; @@ -40,8 +41,7 @@ assert( const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); -clearPathCache(); -invalidateStateCache(); +invalidateAllCaches(); const state2 = await deriveState(tmpBase); assert( state2.phase === "pre-planning", @@ -67,8 +67,7 @@ assert( ); // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) -clearPathCache(); -invalidateStateCache(); +invalidateAllCaches(); const state3 = await deriveState(tmpBase); assert( state3.phase === "pre-planning", diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 51989d732..c7f69993f 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1020,6 +1020,138 @@ async function main(): Promise { rmSync(repo, { recursive: true, force: true }); } + // ─── commit_docs: false — smartStage excludes .gsd/ ────────────────── + + console.log("\n=== commit_docs: false — smartStage excludes .gsd/ ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-commit-docs-")); + run("git init -b main", repo); + run("git config user.email test@test.com", repo); + run("git config user.name Test", repo); + writeFileSync(join(repo, "README.md"), "init"); + run("git add -A && git commit -m init", repo); + + // Create .gsd/ planning files + a normal source file + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap"); + writeFileSync(join(repo, ".gsd", "preferences.md"), "---\nversion: 1\n---"); + writeFileSync(join(repo, "src.ts"), "const x = 1;"); + + // With commit_docs: false, smartStage should exclude .gsd/ + const svc = new GitServiceImpl(repo, { commit_docs: false }); + const msg = svc.commit({ message: "test commit" }); + assertTrue(msg !== null, "commit_docs=false: commit succeeds with non-.gsd files"); + + // .gsd/ files should NOT be in the commit + const committed = run("git show --name-only HEAD", repo); + assertTrue(!committed.includes(".gsd/"), "commit_docs=false: .gsd/ files not in commit"); + assertTrue(committed.includes("src.ts"), "commit_docs=false: source files ARE in commit"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── commit_docs: true (default) — smartStage includes .gsd/ ──────── + + console.log("\n=== commit_docs: true — smartStage includes .gsd/ ==="); + + { + const repo = mkdtempSync(join(tmpdir(), "gsd-commit-docs-default-")); + run("git init -b main", repo); + run("git config user.email test@test.com", repo); + run("git config user.name Test", repo); + writeFileSync(join(repo, "README.md"), "init"); + run("git add -A && git commit -m init", repo); + + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap"); + writeFileSync(join(repo, "src.ts"), "const x = 1;"); + + // Default behavior (commit_docs not set) — .gsd/ files ARE committed + const svc = new GitServiceImpl(repo); + const msg = svc.commit({ message: "test commit" }); + assertTrue(msg !== null, "commit_docs=default: commit succeeds"); + + const committed = run("git show --name-only HEAD", repo); + assertTrue(committed.includes(".gsd/"), "commit_docs=default: .gsd/ files ARE in commit"); + assertTrue(committed.includes("src.ts"), "commit_docs=default: source files in commit"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── writeIntegrationBranch: commitDocs false skips commit ────────── + + console.log("\n=== writeIntegrationBranch: commitDocs false skips commit ==="); + + { + const repo = initBranchTestRepo(); + const commitsBefore = run("git rev-list --count HEAD", repo); + + writeIntegrationBranch(repo, "M001", "f-123-new-thing", { commitDocs: false }); + + // File should still be written to disk + assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing", + "commitDocs=false: metadata file exists on disk"); + + // But no new commit should have been created + const commitsAfter = run("git rev-list --count HEAD", repo); + assertEq(commitsBefore, commitsAfter, + "commitDocs=false: no git commit created for integration branch"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── ensureGitignore: commit_docs false adds blanket .gsd/ ────────── + + console.log("\n=== ensureGitignore: commit_docs false ==="); + + { + const { ensureGitignore } = await import("../gitignore.ts"); + const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-commit-docs-")); + + // When commit_docs is false, should add blanket .gsd/ to gitignore + const modified = ensureGitignore(repo, { commitDocs: false }); + assertTrue(modified, "commit_docs=false: gitignore was modified"); + + const { readFileSync } = await import("node:fs"); + const content = readFileSync(join(repo, ".gitignore"), "utf-8"); + assertTrue(content.includes(".gsd/"), "commit_docs=false: .gitignore contains blanket .gsd/"); + assertTrue(content.includes("commit_docs: false"), "commit_docs=false: .gitignore contains explanatory comment"); + + // Should NOT contain individual runtime patterns (those are subsumed by blanket .gsd/) + // But it's OK if it does — the blanket .gsd/ covers everything + + // Idempotent — calling again doesn't add duplicates + const modified2 = ensureGitignore(repo, { commitDocs: false }); + assertTrue(!modified2, "commit_docs=false: second call is idempotent"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── ensureGitignore: commit_docs true removes blanket .gsd/ ──────── + + console.log("\n=== ensureGitignore: commit_docs true self-heals ==="); + + { + const { ensureGitignore } = await import("../gitignore.ts"); + const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-selfheal-")); + + // Start with a gitignore that has a blanket .gsd/ (e.g., user switched setting) + writeFileSync(join(repo, ".gitignore"), ".gsd/\n"); + + const modified = ensureGitignore(repo, { commitDocs: true }); + assertTrue(modified, "commit_docs=true: gitignore was modified"); + + const { readFileSync } = await import("node:fs"); + const content = readFileSync(join(repo, ".gitignore"), "utf-8"); + // Blanket .gsd/ should be removed + const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); + assertTrue(!lines.includes(".gsd/"), "commit_docs=true: blanket .gsd/ was removed"); + assertTrue(!lines.includes(".gsd"), "commit_docs=true: blanket .gsd was removed"); + + rmSync(repo, { recursive: true, force: true }); + } + report(); } diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index 1cbdba021..b01fed2bb 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -502,7 +502,7 @@ Built the legacy feature successfully. // When run via vitest, wrap in test(); when run via tsx, call directly. const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST; if (isVitest) { - const { test } = await import('vitest'); + const { test } = await import('node:test'); test('integration-mixed-milestones: all groups pass', async () => { await main(); }); diff --git a/src/resources/extensions/gsd/tests/overrides.test.ts b/src/resources/extensions/gsd/tests/overrides.test.ts new file mode 100644 index 000000000..f8302d03c --- /dev/null +++ b/src/resources/extensions/gsd/tests/overrides.test.ts @@ -0,0 +1,131 @@ +// GSD Extension - Override Tests +// Tests for parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides + +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createTestContext } from './test-helpers.ts'; +import { parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides } from '../files.ts'; +import type { Override } from '../files.ts'; + +const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), `gsd-overrides-test-${prefix}-`)); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + tempDirs.push(dir); + return dir; +} + +function cleanup(): void { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; +} + +console.log('\n=== parseOverrides: empty content ==='); +{ const result = parseOverrides(""); assertEq(result.length, 0, "empty content returns no overrides"); } + +console.log('\n=== parseOverrides: single active override ==='); +{ + const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`; + const result = parseOverrides(content); + assertEq(result.length, 1, "parses one override"); + assertEq(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp"); + assertEq(result[0].change, "Use Postgres instead of SQLite", "correct change"); + assertEq(result[0].scope, "active", "correct scope"); + assertEq(result[0].appliedAt, "M001/S02/T03", "correct appliedAt"); +} + +console.log('\n=== parseOverrides: multiple overrides, mixed scopes ==='); +{ + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`; + const result = parseOverrides(content); + assertEq(result.length, 2, "parses two overrides"); + assertEq(result[0].scope, "resolved", "first is resolved"); + assertEq(result[1].scope, "active", "second is active"); + assertEq(result[1].change, "Use JWT instead of session cookies", "second change text"); +} + +console.log('\n=== appendOverride: creates new file ==='); +{ + const tmp = makeTempDir("append-new"); + await appendOverride(tmp, "Use Postgres", "M001/S01/T01"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assertTrue(content.includes("# GSD Overrides"), "has header"); + assertTrue(content.includes("**Change:** Use Postgres"), "has change"); + assertTrue(content.includes("**Scope:** active"), "has active scope"); + assertTrue(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt"); +} + +console.log('\n=== appendOverride: appends to existing file ==='); +{ + const tmp = makeTempDir("append-existing"); + await appendOverride(tmp, "First override", "M001/S01/T01"); + await appendOverride(tmp, "Second override", "M001/S02/T02"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assertTrue(content.includes("**Change:** First override"), "has first override"); + assertTrue(content.includes("**Change:** Second override"), "has second override"); + const parsed = parseOverrides(content); + assertEq(parsed.length, 2, "two overrides in file"); +} + +console.log('\n=== loadActiveOverrides: no file ==='); +{ + const tmp = makeTempDir("load-no-file"); + const result = await loadActiveOverrides(tmp); + assertEq(result.length, 0, "returns empty when no file"); +} + +console.log('\n=== loadActiveOverrides: filters to active only ==='); +{ + const tmp = makeTempDir("load-filter"); + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`; + writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8"); + const result = await loadActiveOverrides(tmp); + assertEq(result.length, 1, "only one active override"); + assertEq(result[0].change, "Active change", "correct active change"); +} + +console.log('\n=== formatOverridesSection: empty array ==='); +{ const result = formatOverridesSection([]); assertEq(result, "", "empty overrides returns empty string"); } + +console.log('\n=== formatOverridesSection: formats section ==='); +{ + const overrides: Override[] = [ + { timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" }, + ]; + const result = formatOverridesSection(overrides); + assertTrue(result.includes("## Active Overrides (supersede plan content)"), "has header"); + assertTrue(result.includes("**Use Postgres**"), "has change text"); + assertTrue(result.includes("supersede any conflicting content"), "has instruction"); +} + +console.log('\n=== resolveAllOverrides: marks all as resolved ==='); +{ + const tmp = makeTempDir("resolve-all"); + await appendOverride(tmp, "First", "M001/S01/T01"); + await appendOverride(tmp, "Second", "M001/S02/T01"); + let active = await loadActiveOverrides(tmp); + assertEq(active.length, 2, "two active before resolve"); + await resolveAllOverrides(tmp); + active = await loadActiveOverrides(tmp); + assertEq(active.length, 0, "no active after resolve"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + const allOverrides = parseOverrides(content); + assertEq(allOverrides.length, 2, "still two overrides total"); + assertTrue(allOverrides.every(o => o.scope === "resolved"), "all resolved"); +} + +console.log('\n=== resolveAllOverrides: no file — no error ==='); +{ + const tmp = makeTempDir("resolve-no-file"); + await resolveAllOverrides(tmp); + assertTrue(true, "resolveAllOverrides with no file does not throw"); +} + +cleanup(); +report(); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts index 802a75f7c..fc57cf55f 100644 --- a/src/resources/extensions/gsd/tests/preferences-git.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-git.test.ts @@ -1,7 +1,5 @@ -/** - * preferences-git.test.ts — Validates that deprecated git.isolation and - * git.merge_to_main preference fields produce deprecation warnings. - */ +// GSD Git Preferences Tests — validates git.isolation and git.merge_to_main handling +// Copyright (c) 2026 Jeremy McSpadden import { createTestContext } from "./test-helpers.ts"; import { validatePreferences } from "../preferences.ts"; @@ -9,18 +7,27 @@ import { validatePreferences } from "../preferences.ts"; const { assertEq, assertTrue, report } = createTestContext(); async function main(): Promise { - console.log("\n=== git.isolation deprecated ==="); + console.log("\n=== git.isolation ==="); - // Any value produces a deprecation warning + // Valid values are accepted without warnings { - const { warnings } = validatePreferences({ git: { isolation: "worktree" } }); - assertTrue(warnings.length > 0, "isolation: worktree — produces deprecation warning"); - assertTrue(warnings[0].includes("deprecated"), "isolation: worktree — warning mentions deprecated"); + const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "worktree" } }); + assertEq(errors.length, 0, "isolation: worktree — no errors"); + assertEq(warnings.length, 0, "isolation: worktree — no warnings"); + assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved"); } { - const { warnings } = validatePreferences({ git: { isolation: "branch" } }); - assertTrue(warnings.length > 0, "isolation: branch — produces deprecation warning"); - assertTrue(warnings[0].includes("deprecated"), "isolation: branch — warning mentions deprecated"); + const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "branch" } }); + assertEq(errors.length, 0, "isolation: branch — no errors"); + assertEq(warnings.length, 0, "isolation: branch — no warnings"); + assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved"); + } + + // Invalid values produce errors + { + const { errors } = validatePreferences({ git: { isolation: "invalid" as any } }); + assertTrue(errors.length > 0, "isolation: invalid — produces error"); + assertTrue(errors[0].includes("worktree, branch"), "isolation: invalid — error mentions valid values"); } // Undefined passes through without warning @@ -34,12 +41,12 @@ async function main(): Promise { // Any value produces a deprecation warning { - const { warnings } = validatePreferences({ git: { merge_to_main: "milestone" } }); + const { warnings } = validatePreferences({ git: { merge_to_main: "milestone" } as any }); assertTrue(warnings.length > 0, "merge_to_main: milestone — produces deprecation warning"); assertTrue(warnings[0].includes("deprecated"), "merge_to_main: milestone — warning mentions deprecated"); } { - const { warnings } = validatePreferences({ git: { merge_to_main: "slice" } }); + const { warnings } = validatePreferences({ git: { merge_to_main: "slice" } as any }); assertTrue(warnings.length > 0, "merge_to_main: slice — produces deprecation warning"); assertTrue(warnings[0].includes("deprecated"), "merge_to_main: slice — warning mentions deprecated"); } @@ -48,17 +55,45 @@ async function main(): Promise { { const { preferences, warnings } = validatePreferences({ git: { auto_push: true } }); assertEq(warnings.length, 0, "merge_to_main: undefined — no warnings"); - assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set"); + assertEq((preferences.git as any)?.merge_to_main, undefined, "merge_to_main: undefined — not set"); } - console.log("\n=== both deprecated fields together ==="); + console.log("\n=== isolation + deprecated merge_to_main together ==="); { - const { warnings } = validatePreferences({ - git: { isolation: "worktree", merge_to_main: "slice" }, + const { warnings, errors } = validatePreferences({ + git: { isolation: "branch", merge_to_main: "slice" } as any, }); - assertEq(warnings.length, 2, "both deprecated fields — 2 warnings"); - assertTrue(warnings.some(w => w.includes("isolation")), "one warning mentions isolation"); - assertTrue(warnings.some(w => w.includes("merge_to_main")), "one warning mentions merge_to_main"); + assertEq(errors.length, 0, "branch isolation + deprecated merge_to_main — no errors"); + assertEq(warnings.length, 1, "branch isolation + deprecated merge_to_main — 1 warning (merge_to_main only)"); + assertTrue(warnings[0].includes("merge_to_main"), "warning mentions merge_to_main"); + } + + console.log("\n=== git.commit_docs ==="); + + // Valid boolean values accepted + { + const { preferences, errors } = validatePreferences({ git: { commit_docs: false } }); + assertEq(errors.length, 0, "commit_docs: false — no errors"); + assertEq(preferences.git?.commit_docs, false, "commit_docs: false — value preserved"); + } + { + const { preferences, errors } = validatePreferences({ git: { commit_docs: true } }); + assertEq(errors.length, 0, "commit_docs: true — no errors"); + assertEq(preferences.git?.commit_docs, true, "commit_docs: true — value preserved"); + } + + // Invalid type produces error + { + const { errors } = validatePreferences({ git: { commit_docs: "no" as any } }); + assertTrue(errors.length > 0, "commit_docs: string — produces error"); + assertTrue(errors[0].includes("commit_docs"), "commit_docs: string — error mentions commit_docs"); + } + + // Undefined passes through without issue + { + const { preferences, errors } = validatePreferences({ git: { auto_push: true } }); + assertEq(errors.length, 0, "commit_docs: undefined — no errors"); + assertEq(preferences.git?.commit_docs, undefined, "commit_docs: undefined — not set"); } report(); diff --git a/src/resources/extensions/gsd/tests/preferences-hooks.test.ts b/src/resources/extensions/gsd/tests/preferences-hooks.test.ts index 60417aa22..c2786e5e0 100644 --- a/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-hooks.test.ts @@ -2,6 +2,7 @@ // Copyright (c) 2026 Jeremy McSpadden import { createTestContext } from "./test-helpers.ts"; +import type { PreDispatchHookConfig } from "../types.ts"; const { assertEq, assertTrue, report } = createTestContext(); @@ -141,16 +142,16 @@ console.log("\n=== Pre-dispatch action validation ==="); console.log("\n=== Pre-dispatch hook merging ==="); { - const baseHooks = [ - { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "base" }, + const baseHooks: PreDispatchHookConfig[] = [ + { name: "inject", before: ["execute-task"], action: "modify", prepend: "base" }, ]; - const overrideHooks = [ - { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "override" }, - { name: "gate", before: ["plan-slice"], action: "skip" as const }, + const overrideHooks: PreDispatchHookConfig[] = [ + { name: "inject", before: ["execute-task"], action: "modify", prepend: "override" }, + { name: "gate", before: ["plan-slice"], action: "skip" }, ]; - const merged = [...baseHooks]; + const merged: PreDispatchHookConfig[] = [...baseHooks]; for (const hook of overrideHooks) { const idx = merged.findIndex(h => h.name === hook.name); if (idx >= 0) { diff --git a/src/resources/extensions/gsd/tests/preferences-models.test.ts b/src/resources/extensions/gsd/tests/preferences-models.test.ts new file mode 100644 index 000000000..a1e2e0a27 --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences-models.test.ts @@ -0,0 +1,208 @@ +// GSD Extension — Model Preferences Parsing Tests +// Copyright (c) 2026 Jeremy McSpadden + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parsePreferencesMarkdown } from "../preferences.ts"; +import type { GSDModelConfigV2, GSDPhaseModelConfig } from "../preferences.ts"; + +// ═══════════════════════════════════════════════════════════════════════════ +// OpenRouter-style model config parsing (issue #488) +// ═══════════════════════════════════════════════════════════════════════════ + +test("parses OpenRouter model config with org/model IDs and fallbacks", () => { + const content = `--- +version: 1 +models: + research: + # Long-context, high-quality research + retrieval + model: moonshotai/kimi-k2.5 + fallbacks: + - qwen/qwen3.5-397b-a17b + planning: + # Deep, careful reasoning for plans + model: deepseek/deepseek-r1-0528 + fallbacks: + - moonshotai/kimi-k2.5 + - deepseek/deepseek-v3.2 + execution: + model: qwen/qwen3-coder + fallbacks: + - qwen/qwen3-coder-next + - minimax/minimax-m2.5 + completion: + model: qwen/qwen3-next-80b-a3b-instruct + fallbacks: + - deepseek/deepseek-v3.2 + - qwen/qwen-plus-2025-07-28 +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + assert.equal(prefs.version, 1, "version should be 1"); + + const models = prefs.models as GSDModelConfigV2; + assert.ok(models, "models should be defined"); + + // Research phase + const research = models.research as GSDPhaseModelConfig; + assert.ok(research, "research config should exist"); + assert.equal(research.model, "moonshotai/kimi-k2.5", "research primary model"); + assert.deepEqual(research.fallbacks, ["qwen/qwen3.5-397b-a17b"], "research fallbacks"); + + // Planning phase + const planning = models.planning as GSDPhaseModelConfig; + assert.ok(planning, "planning config should exist"); + assert.equal(planning.model, "deepseek/deepseek-r1-0528", "planning primary model"); + assert.deepEqual(planning.fallbacks, ["moonshotai/kimi-k2.5", "deepseek/deepseek-v3.2"], "planning fallbacks"); + + // Execution phase + const execution = models.execution as GSDPhaseModelConfig; + assert.ok(execution, "execution config should exist"); + assert.equal(execution.model, "qwen/qwen3-coder", "execution primary model"); + assert.deepEqual(execution.fallbacks, ["qwen/qwen3-coder-next", "minimax/minimax-m2.5"], "execution fallbacks"); + + // Completion phase + const completion = models.completion as GSDPhaseModelConfig; + assert.ok(completion, "completion config should exist"); + assert.equal(completion.model, "qwen/qwen3-next-80b-a3b-instruct", "completion primary model"); + assert.deepEqual(completion.fallbacks, ["deepseek/deepseek-v3.2", "qwen/qwen-plus-2025-07-28"], "completion fallbacks"); +}); + +test("parses model IDs with colons (OpenRouter variants like :free, :exacto)", () => { + const content = `--- +models: + execution: + model: qwen/qwen3-coder + fallbacks: + - qwen/qwen3-coder:free + - qwen/qwen3-coder:exacto +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "qwen/qwen3-coder", "primary model"); + assert.deepEqual( + execution.fallbacks, + ["qwen/qwen3-coder:free", "qwen/qwen3-coder:exacto"], + "fallbacks with colons should be parsed as strings, not objects", + ); +}); + +test("parses legacy string-per-phase model config", () => { + const content = `--- +models: + research: claude-opus-4-6 + planning: claude-opus-4-6 + execution: claude-sonnet-4-6 + completion: claude-haiku-4-5 +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + + const models = prefs.models as GSDModelConfigV2; + assert.equal(models.research, "claude-opus-4-6", "research as string"); + assert.equal(models.planning, "claude-opus-4-6", "planning as string"); + assert.equal(models.execution, "claude-sonnet-4-6", "execution as string"); + assert.equal(models.completion, "claude-haiku-4-5", "completion as string"); +}); + +test("strips inline YAML comments from values", () => { + const content = `--- +models: + execution: + model: qwen/qwen3-coder # fast coding model + fallbacks: + - minimax/minimax-m2.5 # backup +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "qwen/qwen3-coder", "inline comment stripped from model value"); + assert.deepEqual(execution.fallbacks, ["minimax/minimax-m2.5"], "inline comment stripped from fallback"); +}); + +test("handles Windows line endings (CRLF)", () => { + const content = "---\r\nmodels:\r\n execution:\r\n model: qwen/qwen3-coder\r\n---\r\n"; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed with CRLF line endings"); + + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "qwen/qwen3-coder", "model parsed correctly with CRLF"); +}); + +test("handles model config with explicit provider field", () => { + const content = `--- +models: + execution: + model: claude-opus-4-6 + provider: bedrock + fallbacks: + - claude-sonnet-4-6 +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "claude-opus-4-6", "model value"); + assert.equal(execution.provider, "bedrock", "provider value"); + assert.deepEqual(execution.fallbacks, ["claude-sonnet-4-6"], "fallbacks"); +}); + +test("handles empty models config", () => { + const content = `--- +version: 1 +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed"); + assert.equal(prefs.models, undefined, "models should be undefined when not specified"); +}); + +test("handles comment-only lines between keys without breaking structure", () => { + const content = `--- +models: + # Research models + research: + # Primary research model + model: moonshotai/kimi-k2.5 + # Fallback list + fallbacks: + # Best alternatives + - qwen/qwen3.5-397b-a17b + # Planning models + planning: + model: deepseek/deepseek-r1-0528 +--- +`; + + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs, "preferences should be parsed with comments"); + + const models = prefs.models as GSDModelConfigV2; + const research = models.research as GSDPhaseModelConfig; + assert.equal(research.model, "moonshotai/kimi-k2.5", "model value unaffected by surrounding comments"); + // Note: comments inside arrays (like "# Best alternatives") are treated as array items by the parser + // since the array parser doesn't have comment detection. This is a known limitation. + + const planning = models.planning as GSDPhaseModelConfig; + assert.equal(planning.model, "deepseek/deepseek-r1-0528", "next section unaffected by comments"); +}); diff --git a/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts b/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts new file mode 100644 index 000000000..81a57a88c --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts @@ -0,0 +1,176 @@ +/** + * preferences-schema-validation.test.ts — Validates that schema validation + * detects unknown keys, invalid types, and surfaces warnings correctly. + */ + +import { createTestContext } from "./test-helpers.ts"; +import { validatePreferences } from "../preferences.ts"; +import type { GSDPreferences } from "../preferences.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +async function main(): Promise { + console.log("\n=== unknown keys produce warnings ==="); + + { + const prefs = { typo_key: "value" } as unknown as GSDPreferences; + const { warnings } = validatePreferences(prefs); + assertTrue(warnings.some(w => w.includes("typo_key")), "unknown key 'typo_key' produces warning"); + assertTrue(warnings.some(w => w.includes("unknown")), "warning mentions 'unknown'"); + } + + { + const prefs = { foo: 1, bar: 2 } as unknown as GSDPreferences; + const { warnings } = validatePreferences(prefs); + assertTrue(warnings.some(w => w.includes("foo")), "unknown key 'foo' produces warning"); + assertTrue(warnings.some(w => w.includes("bar")), "unknown key 'bar' produces warning"); + assertEq(warnings.filter(w => w.includes("unknown")).length, 2, "two unknown key warnings"); + } + + console.log("\n=== known keys do NOT produce unknown-key warnings ==="); + + { + const prefs: GSDPreferences = { + version: 1, + uat_dispatch: true, + budget_ceiling: 50, + skill_discovery: "auto", + }; + const { warnings } = validatePreferences(prefs); + const unknownWarnings = warnings.filter(w => w.includes("unknown")); + assertEq(unknownWarnings.length, 0, "valid keys produce no unknown-key warnings"); + } + + console.log("\n=== all GSDPreferences keys are accepted ==="); + + { + const prefs: GSDPreferences = { + version: 1, + always_use_skills: ["skill-a"], + prefer_skills: ["skill-b"], + avoid_skills: ["skill-c"], + skill_rules: [{ when: "testing", use: ["skill-d"] }], + custom_instructions: ["do a thing"], + models: { research: "claude-opus-4-6" }, + skill_discovery: "suggest", + auto_supervisor: { model: "claude-opus-4-6" }, + uat_dispatch: false, + unique_milestone_ids: true, + budget_ceiling: 100, + budget_enforcement: "warn", + context_pause_threshold: 0.8, + notifications: { enabled: true }, + remote_questions: { channel: "slack", channel_id: "C123" }, + git: { auto_push: true }, + post_unit_hooks: [{ name: "test-hook", after: ["execute-task"], prompt: "do it" }], + pre_dispatch_hooks: [{ name: "pre-hook", before: ["execute-task"], action: "skip" }], + }; + const { warnings } = validatePreferences(prefs); + const unknownWarnings = warnings.filter(w => w.includes("unknown")); + assertEq(unknownWarnings.length, 0, "all known keys produce no unknown-key warnings"); + } + + console.log("\n=== invalid value types produce errors ==="); + + { + const prefs = { budget_ceiling: "not-a-number" } as unknown as GSDPreferences; + const { errors, preferences } = validatePreferences(prefs); + assertTrue(errors.some(e => e.includes("budget_ceiling")), "invalid budget_ceiling produces error"); + assertEq(preferences.budget_ceiling, undefined, "invalid budget_ceiling falls back to undefined"); + } + + { + const prefs = { budget_enforcement: "invalid" } as unknown as GSDPreferences; + const { errors, preferences } = validatePreferences(prefs); + assertTrue(errors.some(e => e.includes("budget_enforcement")), "invalid budget_enforcement produces error"); + assertEq(preferences.budget_enforcement, undefined, "invalid budget_enforcement falls back to undefined"); + } + + { + const prefs = { context_pause_threshold: "not-a-number" } as unknown as GSDPreferences; + const { errors, preferences } = validatePreferences(prefs); + assertTrue(errors.some(e => e.includes("context_pause_threshold")), "invalid context_pause_threshold produces error"); + assertEq(preferences.context_pause_threshold, undefined, "invalid context_pause_threshold falls back to undefined"); + } + + { + const prefs = { skill_discovery: "invalid-mode" } as unknown as GSDPreferences; + const { errors, preferences } = validatePreferences(prefs); + assertTrue(errors.some(e => e.includes("skill_discovery")), "invalid skill_discovery produces error"); + assertEq(preferences.skill_discovery, undefined, "invalid skill_discovery falls back to undefined"); + } + + console.log("\n=== valid values pass through correctly ==="); + + { + const { preferences } = validatePreferences({ budget_enforcement: "halt" }); + assertEq(preferences.budget_enforcement, "halt", "valid budget_enforcement passes through"); + } + + { + const { preferences } = validatePreferences({ context_pause_threshold: 0.75 }); + assertEq(preferences.context_pause_threshold, 0.75, "valid context_pause_threshold passes through"); + } + + { + const { preferences } = validatePreferences({ models: { research: "claude-opus-4-6" } }); + assertEq(preferences.models?.research, "claude-opus-4-6", "valid models passes through"); + } + + { + const { preferences } = validatePreferences({ auto_supervisor: { model: "claude-opus-4-6" } }); + assertEq(preferences.auto_supervisor?.model, "claude-opus-4-6", "valid auto_supervisor passes through"); + } + + { + const { preferences } = validatePreferences({ notifications: { enabled: true } }); + assertEq(preferences.notifications?.enabled, true, "valid notifications passes through"); + } + + { + const { preferences } = validatePreferences({ remote_questions: { channel: "slack", channel_id: "C123" } }); + assertEq(preferences.remote_questions?.channel, "slack", "valid remote_questions passes through"); + } + + console.log("\n=== mixed valid/invalid/unknown keys ==="); + + { + const prefs = { + uat_dispatch: true, + totally_made_up: "value", + budget_ceiling: "garbage", + } as unknown as GSDPreferences; + const { preferences, errors, warnings } = validatePreferences(prefs); + + // Valid key works + assertEq(preferences.uat_dispatch, true, "valid uat_dispatch preserved"); + + // Unknown key warned + assertTrue(warnings.some(w => w.includes("totally_made_up")), "unknown key warned"); + + // Invalid value errored and dropped + assertTrue(errors.some(e => e.includes("budget_ceiling")), "invalid budget_ceiling errored"); + assertEq(preferences.budget_ceiling, undefined, "invalid budget_ceiling dropped"); + } + + console.log("\n=== existing behavior preserved ==="); + + // git.isolation is a valid active setting (worktree | branch) — no warnings or errors + { + const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "worktree" } } as GSDPreferences); + const unknownWarnings = warnings.filter(w => w.includes("unknown")); + assertEq(unknownWarnings.length, 0, "git is a known key — no unknown-key warning"); + assertEq(errors.length, 0, "valid git.isolation produces no errors"); + assertEq(preferences.git?.isolation, "worktree", "git.isolation value passes through"); + } + + // git.merge_to_main is deprecated — still produces deprecation warning + { + const { warnings } = validatePreferences({ git: { merge_to_main: true } } as GSDPreferences); + assertTrue(warnings.some(w => w.includes("deprecated")), "deprecated git.merge_to_main still warns"); + } + + report(); +} + +main(); diff --git a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts index a1616888f..2f34f6311 100644 --- a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +++ b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts @@ -33,6 +33,7 @@ async function main(): Promise { console.log("\n=== reassess-roadmap prompt loads and substitutes ==="); { const testVars = { + workingDirectory: "/tmp/test-project", milestoneId: "M099", completedSliceId: "S03", assessmentPath: ".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md", @@ -72,6 +73,7 @@ async function main(): Promise { console.log("\n=== reassess-roadmap contains coverage-check instruction ==="); { const prompt = loadPromptFromWorktree("reassess-roadmap", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", completedSliceId: "S01", assessmentPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", @@ -111,6 +113,7 @@ async function main(): Promise { console.log("\n=== coverage-check requires at-least-one semantics ==="); { const prompt = loadPromptFromWorktree("reassess-roadmap", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", completedSliceId: "S01", assessmentPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index 6b81f0ee6..d682a2b20 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -360,6 +360,7 @@ console.log('\n=== deriveState: completed task with no summary file → executin console.log('\n=== prompt: replan-slice template loads and substitutes variables ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', @@ -378,6 +379,7 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instruction ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', @@ -424,6 +426,7 @@ console.log('\n=== dispatch: diagnoseExpectedArtifact returns REPLAN.md path === console.log('\n=== display: replan-slice prompt template has correct unit header ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', diff --git a/src/resources/extensions/gsd/tests/routing-history.test.ts b/src/resources/extensions/gsd/tests/routing-history.test.ts new file mode 100644 index 000000000..f3e09473c --- /dev/null +++ b/src/resources/extensions/gsd/tests/routing-history.test.ts @@ -0,0 +1,87 @@ +/** + * Routing History — structural tests for adaptive learning module. + * + * Verifies routing-history.ts exports and structure from #579. + * Uses source-level checks to avoid @gsd/pi-coding-agent import chain. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const historySrc = readFileSync(join(__dirname, "..", "routing-history.ts"), "utf-8"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Module Exports +// ═══════════════════════════════════════════════════════════════════════════ + +test("routing-history: exports initRoutingHistory", () => { + assert.ok(historySrc.includes("export function initRoutingHistory"), "should export initRoutingHistory"); +}); + +test("routing-history: exports recordOutcome", () => { + assert.ok(historySrc.includes("export function recordOutcome"), "should export recordOutcome"); +}); + +test("routing-history: exports recordFeedback", () => { + assert.ok(historySrc.includes("export function recordFeedback"), "should export recordFeedback"); +}); + +test("routing-history: exports getAdaptiveTierAdjustment", () => { + assert.ok(historySrc.includes("export function getAdaptiveTierAdjustment"), "should export getAdaptiveTierAdjustment"); +}); + +test("routing-history: exports resetRoutingHistory", () => { + assert.ok(historySrc.includes("export function resetRoutingHistory"), "should export resetRoutingHistory"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Design Constants +// ═══════════════════════════════════════════════════════════════════════════ + +test("routing-history: uses rolling window of 50 entries", () => { + assert.ok(historySrc.includes("ROLLING_WINDOW = 50"), "should use 50-entry rolling window"); +}); + +test("routing-history: failure threshold is 20%", () => { + assert.ok(historySrc.includes("FAILURE_THRESHOLD = 0.20"), "should use 20% failure threshold"); +}); + +test("routing-history: feedback weight is 2x", () => { + assert.ok(historySrc.includes("FEEDBACK_WEIGHT = 2"), "feedback should count 2x"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Type Structure +// ═══════════════════════════════════════════════════════════════════════════ + +test("routing-history: imports ComplexityTier from types.ts", () => { + assert.ok( + historySrc.includes('from "./types.js"') && historySrc.includes("ComplexityTier"), + "should import ComplexityTier from types.ts", + ); +}); + +test("routing-history: defines RoutingHistoryData interface", () => { + assert.ok(historySrc.includes("interface RoutingHistoryData"), "should define RoutingHistoryData"); +}); + +test("routing-history: defines FeedbackEntry interface", () => { + assert.ok(historySrc.includes("interface FeedbackEntry"), "should define FeedbackEntry"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Persistence +// ═══════════════════════════════════════════════════════════════════════════ + +test("routing-history: persists to routing-history.json", () => { + assert.ok(historySrc.includes("routing-history.json"), "should persist to routing-history.json"); +}); + +test("routing-history: has save and load functions", () => { + assert.ok(historySrc.includes("saveHistory") || historySrc.includes("function save"), "should have save"); + assert.ok(historySrc.includes("loadHistory") || historySrc.includes("function load"), "should have load"); +}); diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index 7a392e3fa..dde1276b5 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -214,6 +214,7 @@ async function main(): Promise { let promptThrew = false; try { promptResult = loadPromptFromWorktree('run-uat', { + workingDirectory: '/tmp/test-project', milestoneId, sliceId, uatPath, diff --git a/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts new file mode 100644 index 000000000..d613775df --- /dev/null +++ b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { fork } from "node:child_process"; + +import { writeFileSync } from "node:fs"; +import { + writeLock, + readCrashLock, + clearLock, + isLockProcessAlive, +} from "../crash-recovery.ts"; +import { stopAutoRemote } from "../auto.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── stopAutoRemote ────────────────────────────────────────────────────── + +test("stopAutoRemote returns found:false when no lock file exists", () => { + const base = makeTmpBase(); + try { + const result = stopAutoRemote(base); + assert.equal(result.found, false); + assert.equal(result.pid, undefined); + assert.equal(result.error, undefined); + } finally { + cleanup(base); + } +}); + +test("stopAutoRemote cleans up stale lock (dead PID) and returns found:false", () => { + const base = makeTmpBase(); + try { + // Write a lock with a PID that doesn't exist + writeLock(base, "execute-task", "M001/S01/T01", 3); + // Overwrite PID to a dead one + const lock = readCrashLock(base)!; + const staleData = { ...lock, pid: 999999999 }; + writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(staleData, null, 2), "utf-8"); + + const result = stopAutoRemote(base); + assert.equal(result.found, false, "stale lock should not be found as running"); + + // Lock should be cleaned up + assert.equal(readCrashLock(base), null, "stale lock should be removed"); + } finally { + cleanup(base); + } +}); + +test("stopAutoRemote sends SIGTERM to a live process and returns found:true", async () => { + const base = makeTmpBase(); + + // Spawn a child process that sleeps, acting as a fake auto-mode session + const child = fork( + "-e", + ["process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"], + { stdio: "ignore", detached: false }, + ); + + try { + // Wait for child to be ready + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Write lock with child's PID + const lockData = { + pid: child.pid, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2), "utf-8"); + + const result = stopAutoRemote(base); + assert.equal(result.found, true, "should find running auto-mode"); + assert.equal(result.pid, child.pid, "should return the PID"); + + // Wait for child to exit (it should receive SIGTERM) + const exitCode = await new Promise((resolve) => { + child.on("exit", (code) => resolve(code)); + setTimeout(() => resolve(null), 5000); + }); + // On Windows, SIGTERM is not interceptable — the process exits with code 1 + // rather than running the handler. Accept either clean exit (0) or forced (1). + assert.ok(exitCode !== null, "child should have exited after SIGTERM"); + if (process.platform !== "win32") { + assert.equal(exitCode, 0, "child should have exited cleanly via SIGTERM"); + } + } finally { + try { child.kill("SIGKILL"); } catch { /* already dead */ } + cleanup(base); + } +}); + +// ─── Lock path: original project root vs worktree ──────────────────────── + +test("lock file should be discoverable at project root, not worktree path", () => { + const projectRoot = makeTmpBase(); + const worktreePath = join(projectRoot, ".gsd", "worktrees", "M001"); + mkdirSync(join(worktreePath, ".gsd"), { recursive: true }); + + try { + // Simulate: auto-mode writes lock to project root (the fix) + writeLock(projectRoot, "execute-task", "M001/S01/T01", 0); + + // Second terminal checks project root — should find the lock + const lock = readCrashLock(projectRoot); + assert.ok(lock, "lock should be found at project root"); + assert.equal(lock!.unitType, "execute-task"); + + // Worktree path should NOT have a lock + const worktreeLock = readCrashLock(worktreePath); + assert.equal(worktreeLock, null, "lock should NOT exist at worktree path"); + } finally { + cleanup(projectRoot); + } +}); diff --git a/src/resources/extensions/gsd/tests/token-profile.test.ts b/src/resources/extensions/gsd/tests/token-profile.test.ts new file mode 100644 index 000000000..ebae6c745 --- /dev/null +++ b/src/resources/extensions/gsd/tests/token-profile.test.ts @@ -0,0 +1,263 @@ +/** + * Token Profile — unit tests for M004/S01. + * + * Tests profile resolution, preference merging, phase skip defaults, + * subagent model routing, default-to-balanced behavior, and dispatch + * table guard clauses (source-level structural verification). + * + * Uses source-level checks (readFileSync + string matching) to avoid + * @gsd/pi-coding-agent import resolution issues in dev environments. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ─── Source files for structural checks ─────────────────────────────────── + +const dispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8"); +const preferencesSrc = readFileSync(join(__dirname, "..", "preferences.ts"), "utf-8"); +const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Type Definitions +// ═══════════════════════════════════════════════════════════════════════════ + +test("types: TokenProfile type exported with budget/balanced/quality", () => { + assert.ok(typesSrc.includes("export type TokenProfile"), "TokenProfile should be exported"); + assert.ok(typesSrc.includes("'budget'"), "should include budget"); + assert.ok(typesSrc.includes("'balanced'"), "should include balanced"); + assert.ok(typesSrc.includes("'quality'"), "should include quality"); +}); + +test("types: InlineLevel type exported with full/standard/minimal", () => { + assert.ok(typesSrc.includes("export type InlineLevel"), "InlineLevel should be exported"); + assert.ok(typesSrc.includes("'full'"), "should include full"); + assert.ok(typesSrc.includes("'standard'"), "should include standard"); + assert.ok(typesSrc.includes("'minimal'"), "should include minimal"); +}); + +test("types: PhaseSkipPreferences interface exported", () => { + assert.ok(typesSrc.includes("export interface PhaseSkipPreferences"), "PhaseSkipPreferences should be exported"); + assert.ok(typesSrc.includes("skip_research"), "should include skip_research"); + assert.ok(typesSrc.includes("skip_reassess"), "should include skip_reassess"); + assert.ok(typesSrc.includes("skip_slice_research"), "should include skip_slice_research"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// GSDPreferences Interface +// ═══════════════════════════════════════════════════════════════════════════ + +test("preferences: GSDPreferences includes token_profile field", () => { + assert.ok( + preferencesSrc.includes("token_profile?: TokenProfile"), + "GSDPreferences should have token_profile field", + ); +}); + +test("preferences: GSDPreferences includes phases field", () => { + assert.ok( + preferencesSrc.includes("phases?: PhaseSkipPreferences"), + "GSDPreferences should have phases field", + ); +}); + +test("preferences: GSDModelConfig includes subagent field", () => { + // Check both v1 and v2 configs + const v1Match = preferencesSrc.match(/interface GSDModelConfig\s*\{[^}]*subagent/); + assert.ok(v1Match, "GSDModelConfig should have subagent field"); + const v2Match = preferencesSrc.match(/interface GSDModelConfigV2\s*\{[^}]*subagent/); + assert.ok(v2Match, "GSDModelConfigV2 should have subagent field"); +}); + +test("preferences: KNOWN_PREFERENCE_KEYS includes token_profile and phases", () => { + assert.ok(preferencesSrc.includes('"token_profile"'), "KNOWN_PREFERENCE_KEYS should include token_profile"); + assert.ok(preferencesSrc.includes('"phases"'), "KNOWN_PREFERENCE_KEYS should include phases"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Profile Resolution +// ═══════════════════════════════════════════════════════════════════════════ + +test("profile: resolveProfileDefaults exists and handles all 3 tiers", () => { + assert.ok( + preferencesSrc.includes("export function resolveProfileDefaults"), + "resolveProfileDefaults should be exported", + ); + assert.ok( + preferencesSrc.includes('case "budget"') && + preferencesSrc.includes('case "balanced"') && + preferencesSrc.includes('case "quality"'), + "resolveProfileDefaults should handle all 3 tiers", + ); +}); + +test("profile: budget profile sets phase skips to true", () => { + // Extract the budget case block + const budgetIdx = preferencesSrc.indexOf('case "budget":'); + const balancedIdx = preferencesSrc.indexOf('case "balanced":'); + const budgetBlock = preferencesSrc.slice(budgetIdx, balancedIdx); + assert.ok(budgetBlock.includes("skip_research: true"), "budget should skip research"); + assert.ok(budgetBlock.includes("skip_reassess: true"), "budget should skip reassess"); + assert.ok(budgetBlock.includes("skip_slice_research: true"), "budget should skip slice research"); +}); + +test("profile: balanced profile skips only slice research", () => { + const balancedIdx = preferencesSrc.indexOf('case "balanced":'); + const qualityIdx = preferencesSrc.indexOf('case "quality":'); + const balancedBlock = preferencesSrc.slice(balancedIdx, qualityIdx); + assert.ok(balancedBlock.includes("skip_slice_research: true"), "balanced should skip slice research"); + assert.ok(!balancedBlock.includes("skip_research: true"), "balanced should NOT skip milestone research"); + assert.ok(!balancedBlock.includes("skip_reassess: true"), "balanced should NOT skip reassess"); +}); + +test("profile: quality profile has empty phases (no skips)", () => { + const qualityIdx = preferencesSrc.indexOf('case "quality":'); + const qualityEnd = preferencesSrc.indexOf("}", qualityIdx + 50); + // Look for the return block after case "quality": + const qualityReturn = preferencesSrc.slice(qualityIdx, qualityIdx + 200); + assert.ok( + qualityReturn.includes("phases: {}"), + "quality should have empty phases object (no skips)", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Default Behavior (D046) +// ═══════════════════════════════════════════════════════════════════════════ + +test("profile: resolveEffectiveProfile defaults to balanced (D046)", () => { + assert.ok( + preferencesSrc.includes("export function resolveEffectiveProfile"), + "resolveEffectiveProfile should be exported", + ); + assert.ok( + preferencesSrc.includes('return "balanced"'), + "resolveEffectiveProfile should default to balanced", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Inline Level Mapping +// ═══════════════════════════════════════════════════════════════════════════ + +test("profile: resolveInlineLevel maps profile to inline level", () => { + assert.ok( + preferencesSrc.includes("export function resolveInlineLevel"), + "resolveInlineLevel should be exported", + ); + assert.ok(preferencesSrc.includes('case "budget": return "minimal"'), "budget → minimal"); + assert.ok(preferencesSrc.includes('case "balanced": return "standard"'), "balanced → standard"); + assert.ok(preferencesSrc.includes('case "quality": return "full"'), "quality → full"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Validation +// ═══════════════════════════════════════════════════════════════════════════ + +test("validate: validatePreferences handles token_profile", () => { + assert.ok( + preferencesSrc.includes("preferences.token_profile") && + preferencesSrc.includes("budget, balanced, quality"), + "validatePreferences should validate token_profile enum values", + ); +}); + +test("validate: validatePreferences handles phases object", () => { + assert.ok( + preferencesSrc.includes("preferences.phases") && + preferencesSrc.includes("skip_research") && + preferencesSrc.includes("skip_reassess") && + preferencesSrc.includes("skip_slice_research"), + "validatePreferences should validate phases fields", + ); +}); + +test("validate: phases warns on unknown keys", () => { + assert.ok( + preferencesSrc.includes("knownPhaseKeys") && + preferencesSrc.includes("unknown phases key"), + "validatePreferences should warn on unknown phase keys", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Merge +// ═══════════════════════════════════════════════════════════════════════════ + +test("merge: mergePreferences handles token_profile with nullish coalescing", () => { + assert.ok( + preferencesSrc.includes("token_profile: override.token_profile ?? base.token_profile"), + "mergePreferences should use nullish coalescing for token_profile", + ); +}); + +test("merge: mergePreferences handles phases with spread", () => { + assert.ok( + preferencesSrc.includes("...(base.phases") && preferencesSrc.includes("...(override.phases"), + "mergePreferences should spread phases objects", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Subagent Model Routing +// ═══════════════════════════════════════════════════════════════════════════ + +test("subagent: budget profile sets subagent model", () => { + const budgetIdx = preferencesSrc.indexOf('case "budget":'); + const balancedIdx = preferencesSrc.indexOf('case "balanced":'); + const budgetBlock = preferencesSrc.slice(budgetIdx, balancedIdx); + assert.ok(budgetBlock.includes("subagent:"), "budget profile should set subagent model"); +}); + +test("subagent: resolveModelWithFallbacksForUnit handles subagent unit types", () => { + assert.ok( + preferencesSrc.includes('"subagent"') && preferencesSrc.includes('startsWith("subagent/")'), + "resolveModelWithFallbacksForUnit should handle subagent and subagent/* unit types", + ); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Dispatch Table — Phase Skip Guards +// ═══════════════════════════════════════════════════════════════════════════ + +test("dispatch: research-milestone rule has skip_research guard", () => { + // Find the research-milestone rule and check it has the guard + const ruleIdx = dispatchSrc.indexOf("research-milestone"); + assert.ok(ruleIdx > -1, "should have research-milestone rule"); + // The guard should appear near this rule + assert.ok( + dispatchSrc.includes("skip_research") && dispatchSrc.includes("research-milestone"), + "research-milestone dispatch rule should check phases.skip_research", + ); +}); + +test("dispatch: research-slice rule has skip guards", () => { + const ruleIdx = dispatchSrc.indexOf("research-slice"); + assert.ok(ruleIdx > -1, "should have research-slice rule"); + const afterRule = dispatchSrc.slice(ruleIdx); + assert.ok( + afterRule.includes("skip_research") || afterRule.includes("skip_slice_research"), + "research-slice rule should check skip_research or skip_slice_research", + ); +}); + +test("dispatch: reassess-roadmap rule has skip_reassess guard", () => { + assert.ok( + dispatchSrc.includes("skip_reassess") && dispatchSrc.includes("reassess-roadmap"), + "reassess-roadmap dispatch rule should check phases.skip_reassess", + ); +}); + +test("dispatch: phase skip guards return null (not stop)", () => { + // Verify skip guards use return null pattern + const researchGuard = dispatchSrc.match(/skip_research\).*?return null/s); + assert.ok(researchGuard, "skip_research guard should return null (fall-through)"); + + const reassessGuard = dispatchSrc.match(/skip_reassess\).*?return null/s); + assert.ok(reassessGuard, "skip_reassess guard should return null (fall-through)"); +}); diff --git a/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts b/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts index e5e529c8d..859095c10 100644 --- a/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts +++ b/src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts @@ -207,7 +207,7 @@ async function main(): Promise { // When run via vitest, wrap in test(); when run via tsx, call directly. const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST; if (isVitest) { - const { test } = await import('vitest'); + const { test } = await import('node:test'); test('unique-milestone-ids: all ID primitives handle both formats', async () => { await main(); }); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 52a50d7d4..204832dde 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -238,6 +238,34 @@ export interface HookDispatchResult { export type BudgetEnforcementMode = 'warn' | 'pause' | 'halt'; +export type TokenProfile = 'budget' | 'balanced' | 'quality'; + +export type InlineLevel = 'full' | 'standard' | 'minimal'; + +export type ComplexityTier = 'light' | 'standard' | 'heavy'; + +export interface ClassificationResult { + tier: ComplexityTier; + reason: string; + downgraded: boolean; +} + +export interface TaskMetadata { + fileCount?: number; + dependencyCount?: number; + isNewFile?: boolean; + tags?: string[]; + estimatedLines?: number; + codeBlockCount?: number; + complexityKeywords?: string[]; +} + +export interface PhaseSkipPreferences { + skip_research?: boolean; + skip_reassess?: boolean; + skip_slice_research?: boolean; +} + export interface NotificationPreferences { enabled?: boolean; // default true on_complete?: boolean; // notify on each unit completion diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts index 41b909e37..73ab1e1f5 100644 --- a/src/resources/extensions/gsd/undo.ts +++ b/src/resources/extensions/gsd/undo.ts @@ -5,8 +5,9 @@ import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent"; import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { execFileSync } from "node:child_process"; -import { deriveState, invalidateStateCache } from "./state.js"; +import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js"; +import { deriveState } from "./state.js"; +import { invalidateAllCaches } from "./cache.js"; import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js"; import { sendDesktopNotification } from "./notifications.js"; @@ -101,26 +102,28 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi // 5. Try to revert git commits from activity log let commitsReverted = 0; const activityDir = join(gsdRoot(basePath), "activity"); - if (existsSync(activityDir)) { - const commits = findCommitsForUnit(activityDir, unitType, unitId); - if (commits.length > 0) { - for (const sha of commits.reverse()) { - try { - execFileSync("git", ["revert", "--no-commit", sha], { cwd: basePath, timeout: 10000, stdio: "ignore" }); - commitsReverted++; - } catch { - // Revert conflict or already reverted — skip - try { execFileSync("git", ["revert", "--abort"], { cwd: basePath, timeout: 5000, stdio: "ignore" }); } catch { /* no-op */ } - break; + try { + if (existsSync(activityDir)) { + const commits = findCommitsForUnit(activityDir, unitType, unitId); + if (commits.length > 0) { + for (const sha of commits.reverse()) { + try { + nativeRevertCommit(basePath, sha); + commitsReverted++; + } catch { + // Revert conflict or already reverted — skip + try { nativeRevertAbort(basePath); } catch { /* no-op */ } + break; + } } } } + } finally { + // 6. Re-derive state — always invalidate caches even if git operations fail + invalidateAllCaches(); + await deriveState(basePath); } - // 6. Re-derive state - invalidateStateCache(); - await deriveState(basePath); - // Build result message const results: string[] = [`Undone: ${unitType} (${unitId})`]; results.push(` - Removed from completed-units.json`); @@ -171,6 +174,7 @@ function findFileWithPrefix(dir: string, prefix: string, suffix: string): string export function findCommitsForUnit(activityDir: string, unitType: string, unitId: string): string[] { const safeUnitId = unitId.replace(/\//g, "-"); + const commitSet = new Set(); const commits: string[] = []; try { @@ -193,7 +197,8 @@ export function findCommitsForUnit(activityDir: string, unitType: string, unitId for (const block of blocks) { if (block.type === "tool_result" && typeof block.content === "string") { for (const sha of extractCommitShas(block.content)) { - if (!commits.includes(sha)) { + if (!commitSet.has(sha)) { + commitSet.add(sha); commits.push(sha); } } @@ -208,10 +213,12 @@ export function findCommitsForUnit(activityDir: string, unitType: string, unitId } export function extractCommitShas(content: string): string[] { + const seen = new Set(); const commits: string[] = []; for (const match of content.matchAll(/\[[\w/.-]+\s+([a-f0-9]{7,40})\]/g)) { const sha = match[1]; - if (sha && !commits.includes(sha)) { + if (sha && !seen.has(sha)) { + seen.add(sha); commits.push(sha); } } diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 300ddfa9f..0401064c2 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -31,8 +31,8 @@ import { } from "./worktree-manager.js"; import { inferCommitType } from "./git-service.js"; import type { FileLineStat } from "./worktree-manager.js"; -import { execSync } from "node:child_process"; import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs"; +import { nativeMergeAbort } from "./native-git-bridge.js"; import { join, resolve, sep } from "node:path"; /** @@ -691,7 +691,7 @@ async function handleMerge( if (isConflict) { // Abort the failed merge so the working tree is clean for LLM retry try { - execSync("git merge --abort", { cwd: basePath, stdio: "pipe" }); + nativeMergeAbort(basePath); } catch { /* already clean */ } ctx.ui.notify( diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 6696b7cf8..07979b8ad 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -16,8 +16,24 @@ */ import { existsSync, mkdirSync, realpathSync } from "node:fs"; -import { execSync } from "node:child_process"; import { join, resolve, sep } from "node:path"; +import { + nativeBranchDelete, + nativeBranchExists, + nativeBranchForceReset, + nativeCommit, + nativeDetectMainBranch, + nativeDiffContent, + nativeDiffNameStatus, + nativeDiffNumstat, + nativeGetCurrentBranch, + nativeLogOneline, + nativeMergeSquash, + nativeWorktreeAdd, + nativeWorktreeList, + nativeWorktreePrune, + nativeWorktreeRemove, +} from "./native-git-bridge.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -44,43 +60,7 @@ export interface WorktreeDiffSummary { removed: string[]; } -// ─── Git Helpers ─────────────────────────────────────────────────────────── - -/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ -const GIT_NO_PROMPT_ENV = { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_ASKPASS: "", - GIT_SVN_ID: "", -}; - -/** - * Strip git-svn noise from error messages. - * Some systems have a buggy git-svn Perl module that emits warnings - * on every git invocation. See #404. - */ -function filterGitSvnNoise(message: string): string { - return message - .replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "") - .replace(/Unable to determine upstream SVN information from .*\n?/g, "") - .replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "") - .trim(); -} - -function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } = {}): string { - try { - return execSync(`git ${args.join(" ")}`, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - env: GIT_NO_PROMPT_ENV, - }).trim(); - } catch (error) { - if (opts.allowFailure) return ""; - const message = error instanceof Error ? error.message : String(error); - throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${filterGitSvnNoise(message)}`); - } -} +// ─── Path Helpers ────────────────────────────────────────────────────────── function normalizePathForComparison(path: string): string { const normalized = path @@ -91,18 +71,9 @@ function normalizePathForComparison(path: string): string { } 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]!; - } - if (runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true })) return "main"; - if (runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true })) return "master"; - return runGit(basePath, ["branch", "--show-current"]); + return nativeDetectMainBranch(basePath); } -// ─── Path Helpers ────────────────────────────────────────────────────────── - export function worktreesDir(basePath: string): string { return join(basePath, ".gsd", "worktrees"); } @@ -141,17 +112,16 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: mkdirSync(wtDir, { recursive: true }); // Prune any stale worktree entries from a previous removal - runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + nativeWorktreePrune(basePath); // Check if the branch already exists (leftover from a previous worktree) - const branchExists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], { allowFailure: true }); - const mainBranch = getMainBranch(basePath); + const branchAlreadyExists = nativeBranchExists(basePath, branch); + const mainBranch = nativeDetectMainBranch(basePath); - if (branchExists) { + if (branchAlreadyExists) { // Check if the branch is actively used by an existing worktree. - // `git branch -f` will fail if the branch is checked out somewhere. - const worktreeUsing = runGit(basePath, ["worktree", "list", "--porcelain"], { allowFailure: true }); - const branchInUse = worktreeUsing.includes(`branch refs/heads/${branch}`); + const worktreeEntries = nativeWorktreeList(basePath); + const branchInUse = worktreeEntries.some(entry => entry.branch === branch); if (branchInUse) { throw new Error( @@ -161,10 +131,10 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: } // Reset the stale branch to current main, then attach worktree to it - runGit(basePath, ["branch", "-f", branch, mainBranch]); - runGit(basePath, ["worktree", "add", wtPath, branch]); + nativeBranchForceReset(basePath, branch, mainBranch); + nativeWorktreeAdd(basePath, wtPath, branch); } else { - runGit(basePath, ["worktree", "add", "-b", branch, wtPath, mainBranch]); + nativeWorktreeAdd(basePath, wtPath, branch, true, mainBranch); } return { @@ -177,7 +147,7 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: /** * List all GSD-managed worktrees. - * Parses `git worktree list` and filters to those under .gsd/worktrees/. + * Uses native worktree list and filters to those under .gsd/worktrees/. */ export function listWorktrees(basePath: string): WorktreeInfo[] { const baseVariants = [resolve(basePath)]; @@ -197,27 +167,27 @@ export function listWorktrees(basePath: string): WorktreeInfo[] { seenRoots.add(root.normalized); return true; }); - const rawList = runGit(basePath, ["worktree", "list", "--porcelain"]); - if (!rawList.trim()) return []; + const entries = nativeWorktreeList(basePath); + + if (!entries.length) return []; const worktrees: WorktreeInfo[] = []; - const entries = rawList.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean); for (const entry of entries) { - const lines = entry.split("\n"); - const wtLine = lines.find(l => l.startsWith("worktree ")); - const branchLine = lines.find(l => l.startsWith("branch ")); + if (entry.isBare) continue; - if (!wtLine || !branchLine) continue; + const entryPath = entry.path; + const branch = entry.branch; + + if (!branch) continue; - const entryPath = wtLine.replace("worktree ", ""); - const branch = branchLine.replace("branch refs/heads/", ""); const branchWorktreeName = branch.startsWith("worktree/") ? branch.slice("worktree/".length) : branch.startsWith("milestone/") ? branch.slice("milestone/".length) : null; + const entryVariants = [resolve(entryPath)]; if (existsSync(entryPath)) { entryVariants.push(realpathSync(entryPath)); @@ -271,7 +241,7 @@ export function removeWorktree( const wtPath = worktreePath(basePath, name); const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath; const branch = opts.branch ?? worktreeBranchName(name); - const { deleteBranch = true, force = false } = opts; + const { deleteBranch = true, force = true } = opts; // If we're inside the worktree, move out first — git can't remove an in-use directory const cwd = process.cwd(); @@ -281,26 +251,26 @@ export function removeWorktree( } if (!existsSync(wtPath)) { - runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + nativeWorktreePrune(basePath); if (deleteBranch) { - runGit(basePath, ["branch", "-D", branch], { allowFailure: true }); + try { nativeBranchDelete(basePath, branch, true); } catch { /* branch may not exist */ } } return; } - // Force-remove to handle dirty worktrees - runGit(basePath, ["worktree", "remove", "--force", wtPath], { allowFailure: true }); + // Remove worktree (force if requested, to handle dirty worktrees) + try { nativeWorktreeRemove(basePath, wtPath, force); } catch { /* may fail */ } - // If the directory is still there (e.g. locked), try harder + // If the directory is still there (e.g. locked), try harder with force if (existsSync(wtPath)) { - runGit(basePath, ["worktree", "remove", "--force", "--force", wtPath], { allowFailure: true }); + try { nativeWorktreeRemove(basePath, wtPath, true); } catch { /* may fail */ } } // Prune stale entries so git knows the worktree is gone - runGit(basePath, ["worktree", "prune"], { allowFailure: true }); + nativeWorktreePrune(basePath); if (deleteBranch) { - runGit(basePath, ["branch", "-D", branch], { allowFailure: true }); + try { nativeBranchDelete(basePath, branch, true); } catch { /* branch may not exist */ } } } @@ -314,27 +284,22 @@ function shouldSkipPath(filePath: string): boolean { return false; } -function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary { +function parseDiffNameStatus(entries: { status: string; path: string }[]): WorktreeDiffSummary { const added: string[] = []; const modified: string[] = []; const removed: string[] = []; - if (!diffOutput.trim()) return { added, modified, removed }; - - for (const line of diffOutput.split("\n").filter(Boolean)) { - const [status, ...pathParts] = line.split("\t"); - const filePath = pathParts.join("\t"); - - if (shouldSkipPath(filePath)) continue; + for (const { status, path } of entries) { + if (shouldSkipPath(path)) continue; switch (status) { - case "A": added.push(filePath); break; - case "M": modified.push(filePath); break; - case "D": removed.push(filePath); break; + case "A": added.push(path); break; + case "M": modified.push(path); break; + case "D": removed.push(path); break; default: // Renames, copies — treat as modified if (status?.startsWith("R") || status?.startsWith("C")) { - modified.push(filePath); + modified.push(path); } } } @@ -348,19 +313,13 @@ function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary { */ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - const diffOutput = runGit(basePath, [ - "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/", - ], { allowFailure: true }); + const entries = nativeDiffNameStatus(basePath, mainBranch, branch, ".gsd/", true); - return parseDiffNameStatus(diffOutput); + return parseDiffNameStatus(entries); } -/** - * Diff ALL files between the worktree branch and main branch. - * Returns a summary of added, modified, and removed files across the entire repo. - */ /** * Diff ALL files between the worktree branch and main branch. * Uses direct diff (no merge-base) to show what will actually change @@ -369,13 +328,11 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum */ export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - const diffOutput = runGit(basePath, [ - "diff", "--name-status", mainBranch, branch, - ], { allowFailure: true }); + const entries = nativeDiffNameStatus(basePath, mainBranch, branch); - return parseDiffNameStatus(diffOutput); + return parseDiffNameStatus(entries); } /** @@ -384,22 +341,14 @@ export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSum */ export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - const raw = runGit(basePath, [ - "diff", "--numstat", mainBranch, branch, - ], { allowFailure: true }); - - if (!raw.trim()) return []; + const rawStats = nativeDiffNumstat(basePath, mainBranch, branch); const stats: FileLineStat[] = []; - for (const line of raw.split("\n").filter(Boolean)) { - const [a, r, ...pathParts] = line.split("\t"); - const file = pathParts.join("\t"); - if (shouldSkipPath(file)) continue; - const added = a === "-" ? 0 : parseInt(a ?? "0", 10); - const removed = r === "-" ? 0 : parseInt(r ?? "0", 10); - stats.push({ file, added, removed }); + for (const entry of rawStats) { + if (shouldSkipPath(entry.path)) continue; + stats.push({ file: entry.path, added: entry.added, removed: entry.removed }); } return stats; } @@ -410,11 +359,9 @@ export function diffWorktreeNumstat(basePath: string, name: string): FileLineSta */ export function getWorktreeGSDDiff(basePath: string, name: string): string { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - return runGit(basePath, [ - "diff", `${mainBranch}...${branch}`, "--", ".gsd/", - ], { allowFailure: true }); + return nativeDiffContent(basePath, mainBranch, branch, ".gsd/", undefined, true); } /** @@ -423,13 +370,9 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string { */ export function getWorktreeCodeDiff(basePath: string, name: string): string { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - // Get full diff, then exclude .gsd/ paths - // We use pathspec magic to exclude .gsd/ - return runGit(basePath, [ - "diff", `${mainBranch}...${branch}`, "--", ".", ":(exclude).gsd/", - ], { allowFailure: true }); + return nativeDiffContent(basePath, mainBranch, branch, undefined, ".gsd/", true); } /** @@ -437,11 +380,11 @@ export function getWorktreeCodeDiff(basePath: string, name: string): string { */ export function getWorktreeLog(basePath: string, name: string): string { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); - return runGit(basePath, [ - "log", "--oneline", `${mainBranch}..${branch}`, - ], { allowFailure: true }); + const entries = nativeLogOneline(basePath, mainBranch, branch); + + return entries.map(e => `${e.sha} ${e.message}`).join("\n"); } /** @@ -451,15 +394,19 @@ export function getWorktreeLog(basePath: string, name: string): string { */ export function mergeWorktreeToMain(basePath: string, name: string, commitMessage: string): string { const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); - const current = runGit(basePath, ["branch", "--show-current"]); + const mainBranch = nativeDetectMainBranch(basePath); + const current = nativeGetCurrentBranch(basePath); if (current !== mainBranch) { throw new Error(`Must be on ${mainBranch} to merge. Currently on ${current}.`); } - runGit(basePath, ["merge", "--squash", branch]); - runGit(basePath, ["commit", "-m", commitMessage]); + const result = nativeMergeSquash(basePath, branch); + if (!result.success) { + throw new Error(`Merge conflicts detected in: ${result.conflicts.join(", ")}`); + } + + nativeCommit(basePath, commitMessage); return commitMessage; } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 6ab512c71..32160d08d 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -54,10 +54,10 @@ export function setActiveMilestoneId(basePath: string, milestoneId: string | nul * record when the user starts from a different branch (#300). Always a no-op * if on a GSD slice branch. */ -export function captureIntegrationBranch(basePath: string, milestoneId: string): void { +export function captureIntegrationBranch(basePath: string, milestoneId: string, options?: { commitDocs?: boolean }): void { const svc = getService(basePath); const current = svc.getCurrentBranch(); - writeIntegrationBranch(basePath, milestoneId, current); + writeIntegrationBranch(basePath, milestoneId, current, options); } // ─── Pure Utility Functions (unchanged) ──────────────────────────────────── diff --git a/src/update-check.ts b/src/update-check.ts index 623a36b5a..18dc66cd1 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' +import chalk from 'chalk' import { appRoot } from './app-paths.js' const CACHE_FILE = join(appRoot, '.update-check') @@ -46,14 +47,9 @@ export function writeUpdateCache(cache: UpdateCheckCache, cachePath: string = CA } function printUpdateBanner(current: string, latest: string): void { - const yellow = '\x1b[33m' - const dim = '\x1b[2m' - const reset = '\x1b[0m' - const bold = '\x1b[1m' - process.stderr.write( - ` ${yellow}Update available:${reset} ${dim}v${current}${reset} → ${bold}v${latest}${reset}\n` + - ` ${dim}Run${reset} npm update -g gsd-pi ${dim}or${reset} /gsd:update ${dim}to upgrade${reset}\n\n`, + ` ${chalk.yellow('Update available:')} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` + + ` ${chalk.dim('Run')} npm update -g gsd-pi ${chalk.dim('or')} /gsd:update ${chalk.dim('to upgrade')}\n\n`, ) }