From 84f8a75f521a5116c8f2da1b6cec7de7e6bebcf0 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sun, 8 Mar 2026 11:01:22 -0700 Subject: [PATCH 1/2] feat: implement ai-assisted authoring flow and metadata generation --- .agents/skills/beads/SKILL.md | 177 +++++ .agents/skills/dub-flow-evals/SKILL.md | 93 +++ .agents/skills/dub-flow/SKILL.md | 42 +- .agents/skills/dubstack/SKILL.md | 26 + .beads/dolt-monitor.pid.lock | 0 .gitignore | 2 + AGENTS.md | 10 + QUICKSTART.md | 52 +- README.md | 79 ++ apps/docs/content/docs/commands/create.mdx | 18 + apps/docs/content/docs/commands/submit.mdx | 21 + .../content/docs/contributing/development.mdx | 12 + .../docs/getting-started/quickstart.mdx | 21 + .../docs/content/docs/guides/ai-assistant.mdx | 210 +++-- .../docs/guides/migration-from-graphite.mdx | 2 +- apps/docs/content/docs/index.mdx | 15 +- ...03-08-ai-assistant-metadata-flow-design.md | 351 +++++++++ .../2026-03-08-ai-assistant-metadata-flow.md | 527 +++++++++++++ evalite.config.ts | 17 + package.json | 8 +- packages/cli/evals/dub-flow-metadata.eval.ts | 623 +++++++++++++++ packages/cli/src/commands/ai.test.ts | 68 +- packages/cli/src/commands/ai.ts | 133 ++-- packages/cli/src/commands/config.test.ts | 39 +- packages/cli/src/commands/config.ts | 57 +- packages/cli/src/commands/create.test.ts | 174 +++++ packages/cli/src/commands/create.ts | 219 ++---- packages/cli/src/commands/flow.test.ts | 214 +++++ packages/cli/src/commands/flow.ts | 353 +++++++++ packages/cli/src/commands/submit.test.ts | 336 ++++++++ packages/cli/src/commands/submit.ts | 129 ++- packages/cli/src/index.ts | 116 ++- packages/cli/src/lib/ai-diff-context.test.ts | 94 +++ packages/cli/src/lib/ai-diff-context.ts | 501 ++++++++++++ packages/cli/src/lib/ai-metadata.test.ts | 348 +++++++++ packages/cli/src/lib/ai-metadata.ts | 306 ++++++++ packages/cli/src/lib/config.test.ts | 33 + packages/cli/src/lib/config.ts | 38 +- packages/cli/src/lib/git.test.ts | 33 + packages/cli/src/lib/git.ts | 99 +++ .../cli/src/lib/metadata-templates.test.ts | 99 +++ packages/cli/src/lib/metadata-templates.ts | 73 ++ packages/cli/src/lib/pr-body.test.ts | 128 ++- packages/cli/src/lib/pr-body.ts | 45 +- packages/cli/src/lib/temp-text-file.test.ts | 37 + packages/cli/src/lib/temp-text-file.ts | 33 + packages/cli/src/lib/terminal-render.test.ts | 71 ++ packages/cli/src/lib/terminal-render.ts | 139 ++++ packages/cli/test/commands/flow.test.ts | 106 +++ pnpm-lock.yaml | 739 ++++++++++++++++-- pnpm-workspace.yaml | 5 +- skills/dub-flow/SKILL.md | 42 +- skills/dubstack/SKILL.md | 92 +++ 53 files changed, 6795 insertions(+), 410 deletions(-) create mode 100644 .agents/skills/beads/SKILL.md create mode 100644 .agents/skills/dub-flow-evals/SKILL.md create mode 100644 .beads/dolt-monitor.pid.lock create mode 100644 docs/plans/2026-03-08-ai-assistant-metadata-flow-design.md create mode 100644 docs/plans/2026-03-08-ai-assistant-metadata-flow.md create mode 100644 evalite.config.ts create mode 100644 packages/cli/evals/dub-flow-metadata.eval.ts create mode 100644 packages/cli/src/commands/flow.test.ts create mode 100644 packages/cli/src/commands/flow.ts create mode 100644 packages/cli/src/lib/ai-diff-context.test.ts create mode 100644 packages/cli/src/lib/ai-diff-context.ts create mode 100644 packages/cli/src/lib/ai-metadata.test.ts create mode 100644 packages/cli/src/lib/ai-metadata.ts create mode 100644 packages/cli/src/lib/metadata-templates.test.ts create mode 100644 packages/cli/src/lib/metadata-templates.ts create mode 100644 packages/cli/src/lib/temp-text-file.test.ts create mode 100644 packages/cli/src/lib/temp-text-file.ts create mode 100644 packages/cli/src/lib/terminal-render.test.ts create mode 100644 packages/cli/src/lib/terminal-render.ts create mode 100644 packages/cli/test/commands/flow.test.ts diff --git a/.agents/skills/beads/SKILL.md b/.agents/skills/beads/SKILL.md new file mode 100644 index 0000000..3cbcc33 --- /dev/null +++ b/.agents/skills/beads/SKILL.md @@ -0,0 +1,177 @@ +--- +name: beads +description: Use when tracking work in this repo with bd/beads, especially to find ready issues, create follow-up tasks, wire dependencies, or structure epics and child tasks correctly. +--- + +# Beads Issue Tracking + +Use this skill whenever work in this repository needs to be tracked or updated in `bd`. + +This repo uses `bd` for all task tracking. Do not create markdown TODO lists or ad hoc tracking notes when an issue should exist instead. + +## When To Use + +- starting work and needing the next unblocked issue +- claiming an issue before implementation +- creating follow-up work discovered during coding or review +- sequencing tasks with blockers +- grouping tasks under an epic +- closing work after verification + +## Core Workflow + +1. Find ready work: + +```bash +bd ready --json +``` + +2. Claim the issue you are taking: + +```bash +bd update --claim --json +``` + +3. Inspect details before changing the graph: + +```bash +bd show --json +``` + +4. Create newly discovered work with provenance: + +```bash +bd create "Title" \ + --description="Why this work exists, scope, acceptance" \ + -t task \ + -p 2 \ + --deps discovered-from: \ + --json +``` + +5. Add true execution blockers when order matters: + +```bash +bd dep add --json +``` + +6. Close finished work: + +```bash +bd close --reason "Completed" --json +``` + +## Dependency Rules That Matter + +### `discovered-from` is provenance, not scheduling + +Use `--deps discovered-from:` to show where new work came from. This is useful for traceability, but it does not express execution order. + +### Blocking dependencies are directional + +This command: + +```bash +bd dep add --json +``` + +means: +- `` depends on `` +- `` must finish first + +Equivalent shorthand: + +```bash +bd dep --blocks --json +``` + +Use whichever form is clearer in the moment, but double-check the direction before pressing enter. + +### Epics do not block tasks + +`bd` will reject epic-to-task blockers. If you want a task to belong to an epic, attach it as a child: + +```bash +bd update --parent --json +``` + +Use this pattern: +- `parent-child` for epic grouping +- `dep add` for task sequencing + +### Sequence child tasks explicitly + +If task B should wait for task A, add a blocker even if both share the same epic: + +```bash +bd dep add --json +``` + +Parentage alone does not create execution order. + +## Recommended Patterns + +### Start a new stream of work + +```bash +bd create "Ship feature epic" -t epic -p 1 --json +bd create "Implement first task" --deps discovered-from: -t task -p 1 --json +bd update --parent --json +``` + +### Add a follow-up discovered during implementation + +```bash +bd create "Handle template edge case" \ + --description="Found while implementing AI metadata support. Capture the edge case and acceptance criteria." \ + -t task \ + -p 2 \ + --deps discovered-from: \ + --json +``` + +Then decide whether it also needs to block another issue: + +```bash +bd dep add --json +``` + +### Verify your graph + +Use: + +```bash +bd show --json +bd ready --json +``` + +`bd dep tree ` is useful for blocker chains, but it is not the best way to verify epic child membership. + +## Practical Lessons From This Repo + +- Always use `--json` for machine-readable output. +- Prefer serial `bd` writes. Parallel reads are usually fine, but parallel writes can trigger avoidable Dolt hiccups. +- If `bd` reports that the Dolt server auto-started but is unreachable, retry the command once and inspect [`.beads/dolt-server.log`](/Users/wise/dev/dubstack/.beads/dolt-server.log) if it repeats. +- `nothing to commit` warnings can appear in Dolt logs during normal `bd` activity; focus on whether the requested issue change actually landed. +- Use clear descriptions with scope, constraints, and acceptance criteria so the next agent can execute without guesswork. + +## Quick Reference + +| Intent | Command | +|---|---| +| Find unblocked work | `bd ready --json` | +| Claim work | `bd update --claim --json` | +| Inspect an issue | `bd show --json` | +| Create a task | `bd create "Title" ... --json` | +| Link discovered work | `--deps discovered-from:` | +| Make B wait on A | `bd dep add --json` | +| Attach task to epic | `bd update --parent --json` | +| Close work | `bd close --reason "Completed" --json` | + +## Common Mistakes + +- Creating a follow-up issue without `discovered-from`, which loses provenance. +- Using epic parentage as if it also enforced execution order. +- Reversing blocker direction in `bd dep add`. +- Trying to make an epic block a task instead of attaching the task as a child. +- Leaving an issue unclaimed while starting implementation. diff --git a/.agents/skills/dub-flow-evals/SKILL.md b/.agents/skills/dub-flow-evals/SKILL.md new file mode 100644 index 0000000..438c502 --- /dev/null +++ b/.agents/skills/dub-flow-evals/SKILL.md @@ -0,0 +1,93 @@ +--- +name: dub-flow-evals +description: Use when changing AI-generated branch, commit, or PR metadata for DubStack flow, or when adding new local Evalite coverage for those outputs. +--- + +# Dub Flow Evals + +Use this skill when `dub flow` metadata quality could change and you need to validate it with the local Evalite harness. + +## When To Use + +- Prompt or rubric changes in `packages/cli/src/lib/ai-metadata.ts` +- `dub flow` behavior changes that affect generated branch names, commit messages, or PR descriptions +- Template-preservation changes for commit or PR bodies +- New edge cases that should become permanent eval fixtures + +## Quick Run + +```bash +pnpm evals +pnpm evals:watch +pnpm evals:export +``` + +The first suite lives at `packages/cli/evals/dub-flow-metadata.eval.ts`. + +## Core Pattern + +Target the pure helper, not the mutating command: + +- evaluate `generateFlowMetadata(...)` +- keep git mutation coverage in tests +- keep generation quality coverage in Evalite + +This keeps the eval deterministic enough to debug while still exercising the exact AI path shipped by `dub flow`. + +## Case Design + +Each case should include: + +- `parentBranch` +- `stagedDiff` +- optional `commitTemplate` +- optional `prTemplate` +- optional unrelated-noise text for negative assertions + +Prefer curated diffs over giant real snapshots. Keep enough signal for the model to infer intent, but not so much noise that failures become hard to interpret. + +Add cases for: + +- new product behavior +- template edge cases +- regressions found in review or user reports +- confusing diffs where the model might overfit to tests/docs instead of the feature + +## Scoring Pattern + +Use a hybrid scorer set: + +- deterministic contract scorers for branch format, conventional commit subject, template heading preservation, no markdown fences, and non-empty content +- keyword/focus checks for must-mention and must-not-mention terms +- one AI judge scorer for overall reviewer usefulness and diff fidelity + +Do not score exact wording. That makes the suite brittle and rewards prompt overfitting instead of better metadata. + +## Implementation Notes + +- Reuse existing DubStack AI env vars. Do not add eval-only secrets unless absolutely necessary. +- Keep the judge prompt strict about JSON output and parse failures as low scores with metadata. +- Prefer adding a new scorer only when a failure mode is structural. If the issue is general quality, strengthen the judge rubric or add a focused case first. + +## Important Nuance + +Do not enable `traceAISDKModel` in this repo's Evalite suite unless you verify compatibility first. + +The current stack uses `ai@6`, and the traced-model path caused Evalite SQLite trace persistence failures during setup. Plain AI SDK calls work for the suite today, so start there and only reintroduce tracing after confirming it stores cleanly. + +## Workflow + +1. Add or update a case in `packages/cli/evals/dub-flow-metadata.eval.ts`. +2. Run `pnpm evals`. +3. If the failure is structural, add or refine a deterministic scorer. +4. If the failure is quality-related, improve the prompt/helper or judge rubric. +5. Re-run `pnpm evals:watch` while iterating. +6. Export a report with `pnpm evals:export` when you want a shareable artifact. + +## Common Mistakes + +- Evaluating `flow.ts` end-to-end instead of the pure helper +- Writing exact-string assertions into eval scorers +- Letting tests/docs noise dominate the expected intent +- Adding new env vars for the judge when existing provider config already works +- Treating a judge-score dip as prompt-only work when the real issue is missing structural validation diff --git a/.agents/skills/dub-flow/SKILL.md b/.agents/skills/dub-flow/SKILL.md index b8a37bd..9af22ac 100644 --- a/.agents/skills/dub-flow/SKILL.md +++ b/.agents/skills/dub-flow/SKILL.md @@ -5,21 +5,23 @@ description: Use when turning staged changes into a DubStack branch, commit, and # DubStack PR Flow -Use this skill when a user asks to "create a PR" or "submit this" from staged changes. +Use this skill when a user asks to "create a PR" or "submit this" from staged changes, especially when you want the CLI path to mirror `dub flow`. ## Goal Produce a clean, reviewable stack operation with: 1. suggested branch name 2. suggested commit message -3. optional issue linkage -4. execution via `dub create` and `dub submit` +3. suggested PR description +4. optional issue linkage +5. execution via `dub flow` when AI is available, or `dub create` + `dub submit` when manual mode is required ## Preconditions - Current directory is a git repo. - Staged changes exist (or user explicitly wants help staging). - `gh` auth is configured for PR operations. +- If using AI flow, `dub config ai-assistant on` is enabled for the repo. ## Phase 1: Analyze Changes @@ -75,15 +77,33 @@ If user provided issue ID (for example `A-35`), append: Completes A-35 ``` -### PR title/body guidance +### PR description guidance -Since `dub ss` manages stack submission, focus on high-quality commit messages and branch names first. If user asks to polish PR text, prepare concise title/body recommendations after submission. +- PR title should stay equal to the commit subject for squash-merge safety. +- AI-generated PR text should focus on the description body only. +- If the repo has a PR template, preserve its headings and section order. + +### Template-aware metadata + +If the repo has templates, use them as the formatting contract: + +- PR template locations: + - `.github/pull_request_template.md` + - `.github/PULL_REQUEST_TEMPLATE.md` + - `.github/PULL_REQUEST_TEMPLATE/*.md` + - `docs/pull_request_template.md` + - `pull_request_template.md` +- Commit template: + - configure with `git config commit.template .gitmessage` + +The first line of the commit message still must be a valid Conventional Commit subject. ## Phase 3: Confirm Before Execution Present: - suggested branch name - suggested commit message +- suggested PR description - what command you plan to run Ask user to choose: @@ -93,6 +113,16 @@ Ask user to choose: ## Phase 4: Execute +### Preferred AI path + +```bash +dub flow --ai -a +``` + +- `dub flow` previews branch, commit, PR, and planned commands before mutation. +- In non-interactive terminals, add `-y` because approval prompts require a TTY. +- Use `dub f --dry-run` when you only need preview output. + ### Default path (stage all) ```bash @@ -125,6 +155,8 @@ dub pr - **No staged changes**: ask user to stage files or choose `-a/-u/-p` flow. - **Branch exists already**: suggest alternate name. - **GitHub auth errors**: prompt `gh auth login`. +- **AI not enabled**: prompt `dub config ai-assistant on` and configure repo defaults if appropriate. +- **Non-interactive terminal**: rerun with `-y` or use `--dry-run` if the user only wants preview output. - **Submit conflicts/restack issues**: run `dub restack`, resolve conflicts, then rerun `dub ss`. ## Success Output Template diff --git a/.agents/skills/dubstack/SKILL.md b/.agents/skills/dubstack/SKILL.md index c7dd333..a478a2a 100644 --- a/.agents/skills/dubstack/SKILL.md +++ b/.agents/skills/dubstack/SKILL.md @@ -160,6 +160,32 @@ dub trunk 5. Iterate with `dub m ...` and `dub ss` 6. Keep updated with `dub sync` (add `--restack` when you want automatic post-sync rebasing) +## AI Workflow + +```bash +dub ai env --gemini-key "" +# or +dub ai env --gateway-key "" + +dub config ai-assistant on +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on +``` + +AI-assisted path: + +1. stage changes or use `-a`, `-u`, or `-p` +2. run `dub flow --ai` +3. review the rendered branch, commit, and PR previews +4. approve or edit +5. let DubStack create and submit + +Notes: + +- In non-interactive terminals, add `-y` because `dub flow` approval prompts require a TTY. +- `dub ai ask` streams response text live and renders tool/status lines separately in TTY output. + ## Recovery Patterns ### Restack conflict diff --git a/.beads/dolt-monitor.pid.lock b/.beads/dolt-monitor.pid.lock new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index aabe58f..6b1c45e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ coverage .worktrees .dispatch +.evalite +.evalite-export # Dolt database files (added by bd init) .dolt/ diff --git a/AGENTS.md b/AGENTS.md index 6158630..cc5f2a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,10 @@ Use these commands from the repo root: - `pnpm typecheck` - `pnpm checks` - `pnpm checks:fix` +- `pnpm check:all` +- `pnpm evals` +- `pnpm evals:watch` +- `pnpm evals:export` - `pnpm build` ## 3) Repository Structure @@ -41,6 +45,8 @@ Use these commands from the repo root: - Agent skills shipped by this repo: - `skills/dubstack` - `skills/dub-flow` +- Project-only agent skills: + - `.agents/skills/dub-flow-evals` ## 4) Coding Conventions @@ -73,6 +79,10 @@ Use these commands from the repo root: All three must pass. Do not skip any. Do not consider work done until all pass. +If AI metadata generation or prompts changed, also run `pnpm evals`. + +When you want the full local gate in one command, run `pnpm check:all`. + If behavior/output changed, add or update tests near the changed code: - command logic: `packages/cli/src/commands/*.test.ts` diff --git a/QUICKSTART.md b/QUICKSTART.md index af596c8..db834d7 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -22,13 +22,34 @@ source ~/.zshrc # 3) enable assistant for this repo dub config ai-assistant on -# 4) ask a question +# 4) optional: enable AI defaults +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on + +# 5) ask a question dub ai ask "Summarize this stack from trunk to current branch" # optional: inspect recent dub command history/context dub history --limit 20 ``` +Optional template setup: + +```bash +cat <<'EOF' > .gitmessage +feat(scope): summary + +## Testing +- [ ] added coverage +EOF + +git config commit.template .gitmessage +``` + +- add a PR template in `.github/pull_request_template.md` or `.github/PULL_REQUEST_TEMPLATE/*.md` +- DubStack AI will follow those templates for generated commit bodies and PR descriptions + ## 1) Start from Trunk ```bash @@ -66,6 +87,9 @@ dub create feat/new-layer -pm "feat: ..." # AI-generate branch + commit from staged changes dub create --ai +# override repo AI defaults for one invocation +dub create --no-ai feat/new-layer + # stage all, then AI-generate branch + commit (supports -ai shorthand) dub create -ai ``` @@ -101,6 +125,9 @@ dub ss --dry-run # current-path submit (default behavior) dub ss --path current + +# AI-generate PR description body +dub submit --ai ``` Open PR in browser: @@ -132,6 +159,18 @@ dub m -vv dub ss ``` +## Optional: Use The AI Flow + +```bash +# stage all, preview generated metadata, create, and submit +dub flow --ai -a + +# auto-approve after staging tracked files +dub f -y -u +``` + +If you run `dub flow` in a non-interactive terminal, add `-y` because approval prompts require a TTY. + ## 6) Keep Stack in Sync After trunk changes: @@ -260,8 +299,19 @@ dub undo | `dub undo` | Undo last create/restack | | `dub config ai-assistant on` | Enable repo-local AI assistant | | `dub ai ask "..."` | Ask AI assistant (streaming + constrained read-only repo shell tool) | +| `dub flow --ai -a` | Stage, preview, create, and submit with AI | | `dub history` | Show recent Dub command history | +## Local AI Evals + +```bash +pnpm evals +pnpm evals:watch +pnpm evals:export +``` + +Use these when changing AI-generated flow metadata so branch naming, commit bodies, PR descriptions, and template preservation stay honest. + ## Next Step Read [`README.md`](./README.md) for full command details, sync behavior, and troubleshooting. diff --git a/README.md b/README.md index b19a411..5f591fb 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,9 @@ dub create feat/my-change -pm "feat: ..." # AI-generate branch + conventional commit from staged changes dub create --ai +# override repo AI defaults for one invocation +dub create --no-ai feat/my-change + # stage all, then AI-generate branch + commit (supports -ai shorthand) dub create -ai ``` @@ -153,6 +156,9 @@ Flags: - `-u, --update`: stage tracked-file updates before commit (requires `-m` or `--ai`) - `-p, --patch`: select hunks interactively before commit (requires `-m` or `--ai`) - `-i, --ai`: AI-generate branch + conventional commit from staged changes +- `--no-ai`: disable AI generation for this invocation + +If the repo configures `git config commit.template`, DubStack includes that template when generating AI commit messages so the body follows the repo's expected structure. ### `dub modify` / `dub m` @@ -370,6 +376,9 @@ dub ss # preview only dub submit --dry-run +# AI-generate PR description body +dub submit --ai + # submit only current linear path (default) dub submit --path current @@ -380,6 +389,37 @@ dub submit --path stack dub submit --path stack --fix ``` +Notes: +- `--no-ai` disables AI PR description generation for one invocation. +- AI submit only writes the PR description body; the PR title still comes from the last commit message. +- If the repo has a PR template in a supported GitHub template location, DubStack preserves that structure when generating AI PR descriptions. + +### `dub flow` / `dub f` + +Stage, preview, create, and submit an AI-assisted change. + +```bash +# stage all, preview, create, commit, and submit +dub flow --ai -a + +# auto-approve after staging tracked files +dub flow -y -u + +# preview only +dub f --dry-run +``` + +`dub flow` requires an interactive terminal for approval. In non-interactive environments, pass `-y` to auto-approve after the preview is rendered. + +Flags: +- `-a, --all` +- `-u, --update` +- `-p, --patch` +- `-y, --yes` +- `-i, --ai` +- `--no-ai` +- `--dry-run` + ### `dub pr [branch-or-number]` Open a PR in browser via `gh`. @@ -557,6 +597,20 @@ dub config ai-assistant on dub config ai-assistant off ``` +### `dub config ai-defaults [on|off]` + +Manage repo-local defaults for AI-assisted authoring. + +```bash +# inspect current value +dub config ai-defaults create + +# enable AI by default +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on +``` + ### `dub ai ask ` Ask DubStack's AI assistant using streaming output (`streamText`). @@ -566,6 +620,7 @@ dub ai ask "Summarize what this stack is changing" ``` `dub ai ask` automatically includes a context packet (current branch/stack signals, git status, doctor summary, and recent Dub command history) so it can give better recovery guidance. +In TTY mode, response text streams live while status/tool activity lines are rendered separately for readability. To inspect your repository, `dub ai ask` can invoke a constrained shell tool limited to a strict allow-list of safe, read-only commands (for example `git status`, `dub doctor`, `dub ready`) when command output is needed. The assistant cannot execute arbitrary shell commands; requests outside this allow-list are rejected, and additional safety checks block destructive command patterns. @@ -577,6 +632,30 @@ Provider/key selection: Thinking is enabled by default for Gemini 3 Flash. +Template support: +- PR templates: `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `docs/pull_request_template.md`, `pull_request_template.md` +- commit templates: configured with `git config commit.template ` + +When templates are present, DubStack uses them as the formatting contract for AI-generated commit messages and PR descriptions. + +## AI Evals + +DubStack uses [Evalite](https://v1.evalite.dev/getting-started) for local AI quality checks around generated metadata. + +```bash +# run the curated dub flow metadata suite +pnpm evals + +# rerun on file changes while iterating on prompts/scorers +pnpm evals:watch + +# export the latest local report +pnpm evals:export +``` + +The first suite lives at `packages/cli/evals/dub-flow-metadata.eval.ts` and evaluates the pure `generateFlowMetadata(...)` helper used by `dub flow`. +It mixes deterministic contract checks with an AI judge scorer so prompt changes are measured against staged diff fidelity, template preservation, and reviewer usefulness. + ### `dub ai env` Write DubStack AI keys/models into your shell profile (macOS/Linux shells). diff --git a/apps/docs/content/docs/commands/create.mdx b/apps/docs/content/docs/commands/create.mdx index d9edfeb..f0678b7 100644 --- a/apps/docs/content/docs/commands/create.mdx +++ b/apps/docs/content/docs/commands/create.mdx @@ -24,6 +24,9 @@ dub create feat/my-change -pm "feat: ..." # AI-generate branch + conventional commit from staged changes dub create --ai +# Override repo AI defaults for one command +dub create --no-ai feat/my-change + # Stage all, then AI-generate branch + commit dub create -ai ``` @@ -37,17 +40,32 @@ dub create -ai | `-u, --update` | Stage tracked-file updates before commit (requires `-m` or `--ai`) | | `-p, --patch` | Select hunks interactively before commit (requires `-m` or `--ai`) | | `-i, --ai` | AI-generate branch + conventional commit from staged changes | +| `--no-ai` | Disable AI generation for this invocation, even if repo defaults enable it | ## AI Mode When using `--ai` or `-ai`, DubStack analyzes your staged changes and generates a descriptive branch name and conventional commit message. This requires an AI key configured via `dub ai env`. +If your repository enables AI create defaults, `--no-ai` lets you force manual mode for a single invocation. + +If your repository configures a commit message template with `git config commit.template`, DubStack includes that template when generating AI commit messages so the body follows the repo's expected sections and formatting. + ```bash # AI with explicit staging dub create --ai # Stage all + AI (shorthand) dub create -ai + +# Example commit template setup +cat <<'EOF' > .gitmessage +feat(scope): summary + +## Testing +- [ ] added coverage +EOF + +git config commit.template .gitmessage ``` ## Notes diff --git a/apps/docs/content/docs/commands/submit.mdx b/apps/docs/content/docs/commands/submit.mdx index 491967e..033fa6a 100644 --- a/apps/docs/content/docs/commands/submit.mdx +++ b/apps/docs/content/docs/commands/submit.mdx @@ -12,6 +12,9 @@ dub ss # Preview only dub submit --dry-run +# Generate a PR description body with AI +dub submit --ai + # Submit only current linear path (default) dub submit --path current @@ -27,12 +30,30 @@ dub submit --path stack --fix | Flag | Description | |---|---| | `--dry-run` | Preview submit actions without executing | +| `--ai` | Generate a PR description body for this invocation | +| `--no-ai` | Disable AI PR description generation for this invocation | | `--path current` | Submit only current linear path (default) | | `--path stack` | Submit the whole stack graph | | `--fix` | Auto-fallback to current path when stack-mode is blocked | **Aliases:** `dub ss` +When AI submit defaults are enabled for the repository, `--no-ai` lets you force a manual PR body update for one run. AI submit only writes the PR description body; the PR title still comes from the last commit message. + +If your repository includes a GitHub pull request template, DubStack asks AI to preserve that template structure when generating the PR description body. Supported locations include `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `docs/pull_request_template.md`, and `pull_request_template.md`. + +```bash +mkdir -p .github +cat <<'EOF' > .github/pull_request_template.md +## Summary + +## Testing + +## Checklist +- [ ] Ready for review +EOF +``` + ## Open PR in Browser Use `dub pr` to open a PR in your browser via `gh`: diff --git a/apps/docs/content/docs/contributing/development.mdx b/apps/docs/content/docs/contributing/development.mdx index 4c4e403..36ba16b 100644 --- a/apps/docs/content/docs/contributing/development.mdx +++ b/apps/docs/content/docs/contributing/development.mdx @@ -23,6 +23,9 @@ pnpm install 5. Open a PR using the repository PR template. ```bash +pnpm check:all + +# or run the steps individually pnpm test pnpm typecheck pnpm checks @@ -30,6 +33,15 @@ pnpm checks Before submitting stacked PRs, run `dub ready` to validate health + submit preflight. Changes are not ready to merge unless all three verification commands pass. +If you change AI-generated metadata or prompt behavior, also run the local Evalite suite: + +```bash +pnpm evals +``` + +Use `pnpm evals:watch` while iterating and `pnpm evals:export` to export the latest local report. +`pnpm check:all` already includes unsafe Biome fixes, typecheck, tests, and evals. + ## Formatting and Naming - Biome is the source of truth for formatting/linting. diff --git a/apps/docs/content/docs/getting-started/quickstart.mdx b/apps/docs/content/docs/getting-started/quickstart.mdx index aad389d..127bae7 100644 --- a/apps/docs/content/docs/getting-started/quickstart.mdx +++ b/apps/docs/content/docs/getting-started/quickstart.mdx @@ -36,10 +36,20 @@ source ~/.zshrc # Enable assistant for this repo dub config ai-assistant on +# Optional: enable AI defaults +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on + # Ask a question dub ai ask "Summarize this stack from trunk to current branch" ``` +If you want DubStack AI to follow your repository's formatting, also set up: + +- a PR template in `.github/pull_request_template.md` or `.github/PULL_REQUEST_TEMPLATE/*.md` +- a commit template via `git config commit.template .gitmessage` + ## 1. Start from Trunk ```bash @@ -91,6 +101,16 @@ dub ss --dry-run dub pr ``` +## Optional: Use the AI Flow + +```bash +# Stage all, preview generated metadata, create, and submit +dub flow --ai -a + +# Auto-approve after staging tracked files +dub flow -y -u +``` + ## 5. Respond to Feedback When feedback lands on a middle branch: @@ -156,3 +176,4 @@ dub post-merge | `dub continue` / `dub abort` | Resume/cancel operations | | `dub undo` | Undo last create/restack | | `dub ai ask "..."` | Ask AI assistant | +| `dub flow --ai -a` | Run the AI-assisted authoring flow | diff --git a/apps/docs/content/docs/guides/ai-assistant.mdx b/apps/docs/content/docs/guides/ai-assistant.mdx index 86a2ece..e6752bd 100644 --- a/apps/docs/content/docs/guides/ai-assistant.mdx +++ b/apps/docs/content/docs/guides/ai-assistant.mdx @@ -1,83 +1,203 @@ --- title: AI Commands -description: AI-powered assistance — dub ai ask, dub ai env, dub config ai-assistant, and dub history. +description: Complete guide to DubStack AI setup, shortcuts, metadata generation, flow, templates, and conflict resolution. --- -DubStack includes an AI assistant that understands your stack context, plus commands for managing AI configuration and inspecting command history. +DubStack includes an AI assistant for repository-aware questions, AI-generated branch and commit metadata, AI-generated PR descriptions, and conflict-resolution help. -## dub ai ask +## Setup -Ask DubStack's AI assistant using streaming output. The assistant automatically receives a context packet including current branch/stack signals, git status, doctor summary, and recent command history. +```bash +# Add one API key to your shell profile +dub ai env --gemini-key "" +# or: +dub ai env --gateway-key "" + +# Reload your shell +source ~/.zshrc + +# Enable AI for this repository +dub config ai-assistant on +``` + +You can also set repo-local defaults for AI-assisted authoring: + +```bash +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on +``` + +## Direct Assistant Usage + +Ask the AI assistant explicitly: ```bash dub ai ask "Summarize what this stack is changing" ``` -### Automatic Context +Use the top-level shortcut: + +```bash +dub "what changed on this branch?" +``` + +Force shortcut mode even when the first token could look like a command: -The AI assistant can invoke a constrained shell tool limited to a strict allow-list of safe, read-only commands (for example `git status`, `dub doctor`, `dub ready`) when command output is needed. It cannot execute arbitrary shell commands; requests outside the allow-list are rejected. +```bash +dub --ai "summarize terminal work" +``` -### Provider Selection +The assistant automatically receives stack-aware context such as current branch, stack placement, git status, doctor output, and recent command history. -- If `DUBSTACK_GEMINI_API_KEY` is set, DubStack uses direct Google provider access (`gemini-3-flash`). -- Otherwise, if `DUBSTACK_AI_GATEWAY_API_KEY` is set, DubStack uses Vercel AI Gateway (`google/gemini-3-flash`). -- If both are set, DubStack prefers `DUBSTACK_GEMINI_API_KEY`. +## AI-Assisted Authoring -## dub ai env +### `dub create --ai` -Write DubStack AI keys into your shell profile (macOS/Linux). +Generate a branch name and conventional commit message from staged changes: ```bash -# Write Gemini key -dub ai env --gemini-key "" +dub create --ai +dub create -ai +dub create --no-ai feat/my-change +``` -# Write Gateway key -dub ai env --gateway-key "" +If AI create defaults are enabled for the repo, `--no-ai` disables AI for a single invocation. -# Write both -dub ai env --gemini-key "" --gateway-key "" +### `dub submit --ai` -# Target a specific profile file -dub ai env --gemini-key "" --profile ~/.zshrc +Generate a PR description body while keeping the PR title equal to the last commit message: + +```bash +dub submit --ai +dub submit --no-ai ``` -| Flag | Description | -|---|---| -| `--gemini-key ` | Set `DUBSTACK_GEMINI_API_KEY` | -| `--gateway-key ` | Set `DUBSTACK_AI_GATEWAY_API_KEY` | -| `--profile ` | Target specific profile file (auto-detects zsh/bash otherwise) | +AI submit only writes the PR description body. DubStack still owns: -## dub config ai-assistant +- the stack table +- the hidden metadata block -Enable or disable the repo-local AI assistant flag. +### `dub flow` and `dub f` + +Use the end-to-end AI workflow for staged changes: ```bash -# Check current value -dub config ai-assistant +dub flow --ai -a +dub flow -y -u +dub f --dry-run +``` -# Enable -dub config ai-assistant on +`dub flow` previews: + +- generated branch name +- generated commit message +- generated PR description +- the commands DubStack will run + +Use `-y` to auto-approve. Use `--dry-run` to preview without creating or submitting anything. +If you run `dub flow` in a non-interactive terminal, `-y` is required because approval prompts need a TTY. + +`dub ai ask` streams response text as it arrives and renders tool/status lines separately when the output stream is a TTY. + +## Defaults and Precedence + +DubStack resolves AI usage in this order: + +1. command flag such as `--ai` or `--no-ai` +2. repo-local default from `dub config ai-defaults ...` +3. built-in fallback of off + +Examples: -# Disable -dub config ai-assistant off +```bash +# Enable AI by default for create +dub config ai-defaults create on + +# Force manual mode one time +dub create --no-ai feat/manual-branch + +# Enable AI by default for submit PR descriptions +dub config ai-defaults submit on ``` -## dub history +## Templates + +DubStack can use repository templates as the formatting contract for AI-generated commit messages and PR descriptions. + +### Pull Request Templates -Inspect recent Dub command history used for troubleshooting context. +DubStack checks common GitHub PR template locations, including: + +- `.github/pull_request_template.md` +- `.github/PULL_REQUEST_TEMPLATE.md` +- `.github/PULL_REQUEST_TEMPLATE/*.md` +- `docs/pull_request_template.md` +- `pull_request_template.md` + +When a PR template exists, `dub submit --ai` and `dub flow` ask the AI to preserve that structure instead of inventing a generic PR description. + +Example: ```bash -# Show last 20 entries -dub history +mkdir -p .github +cat <<'EOF' > .github/pull_request_template.md +## Summary + +## Testing -# Show more -dub history --limit 50 +## Checklist +- [ ] Ready for review +EOF +``` + +### Commit Message Templates + +DubStack also reads the repository commit message template from git config: + +```bash +cat <<'EOF' > .gitmessage +feat(scope): summary + +## Testing +- [ ] added coverage +EOF + +git config commit.template .gitmessage +``` -# Machine-readable output -dub history --json +When a commit template is configured, `dub create --ai` and `dub flow` preserve that shape in the generated commit message body. The first line still needs to be a valid Conventional Commit subject. + +If no template is present, DubStack falls back to its default AI formatting. + +## Conflict Resolution + +Use AI to help resolve conflicts: + +```bash +dub ai resolve +``` + +If you are already in a continue flow: + +```bash +dub continue --ai +``` + +## Notes + +- AI requires `dub config ai-assistant on` in the current repository. +- If AI is explicitly requested and unavailable, DubStack fails clearly instead of silently downgrading. +- PR titles remain commit-derived for squash-merge safety. + +## Local Evals + +DubStack keeps a local Evalite suite for AI-generated flow metadata: + +```bash +pnpm evals +pnpm evals:watch +pnpm evals:export ``` -| Flag | Description | -|---|---| -| `--limit ` | Number of entries to show (default: 20) | -| `--json` | Output in JSON format | +The first suite targets the same pure `generateFlowMetadata(...)` helper used by `dub flow`, combining structural contract checks with an AI judge scorer. diff --git a/apps/docs/content/docs/guides/migration-from-graphite.mdx b/apps/docs/content/docs/guides/migration-from-graphite.mdx index 3560022..93024c6 100644 --- a/apps/docs/content/docs/guides/migration-from-graphite.mdx +++ b/apps/docs/content/docs/guides/migration-from-graphite.mdx @@ -33,6 +33,6 @@ If you have `gt` muscle memory, use this as a fast map. DubStack follows the sam ## Key Differences - **Local-first** — DubStack stores all state locally in `.git/dubstack/`. Nothing is pushed to your remote from these files. -- **AI features** — DubStack includes built-in AI for branch naming, commit messages, and stack-aware guidance via `dub ai ask`. +- **AI features** — DubStack includes built-in AI for branch naming, commit messages, PR descriptions, `dub flow`, and stack-aware guidance via `dub ai ask`. - **Agent skills** — DubStack ships packaged skills for coding assistants via `dub skills add`. - **Safe merging** — `dub merge-next` handles retargeting child PRs before deleting merged branches. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 437548e..c28eec4 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -3,7 +3,7 @@ title: Introduction description: DubStack is a CLI for stacked diffs — dependent git branches that make code review faster and more focused. --- -DubStack is a CLI tool for managing stacked diffs — dependent git branches that make code review faster and more focused. It stores all state locally in `.git/dubstack/`, uses GitHub PRs directly via the `gh` CLI, and includes built-in AI assistance for branch naming, commit messages, and stack-aware guidance. +DubStack is a CLI tool for managing stacked diffs — dependent git branches that make code review faster and more focused. It stores all state locally in `.git/dubstack/`, uses GitHub PRs directly via the `gh` CLI, and includes built-in AI assistance for stack-aware guidance, branch naming, commit messages, PR descriptions, and end-to-end flow previews. ## Prerequisites @@ -125,8 +125,20 @@ source ~/.zshrc # Enable assistant for this repo dub config ai-assistant on +# Optional: enable AI defaults +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on + # Ask a question dub ai ask "Summarize this stack from trunk to current branch" + +# Generate metadata directly +dub create --ai +dub submit --ai + +# Run the full AI flow +dub flow --ai -a ``` ## Command Reference @@ -147,3 +159,4 @@ dub ai ask "Summarize this stack from trunk to current branch" | `dub continue` / `dub abort` | Resume/cancel operations | | `dub undo` | Undo last create/restack | | `dub ai ask "..."` | Ask AI assistant | +| `dub flow --ai -a` | Stage, preview, create, and submit with AI | diff --git a/docs/plans/2026-03-08-ai-assistant-metadata-flow-design.md b/docs/plans/2026-03-08-ai-assistant-metadata-flow-design.md new file mode 100644 index 0000000..8927b94 --- /dev/null +++ b/docs/plans/2026-03-08-ai-assistant-metadata-flow-design.md @@ -0,0 +1,351 @@ +# AI Assistant Metadata and Flow Design + +## Summary + +DubStack already supports AI-assisted branch naming and commit message generation through `dub create --ai`, plus conversational help through `dub ai ask` and conflict resolution through `dub ai resolve`. This design extends AI from an isolated create-time shortcut into a consistent authoring workflow across branch creation, PR submission, and a new end-to-end `dub flow` command. + +The design keeps AI optional, explicit, and safe: + +- AI remains gated by `dub config ai-assistant on`. +- Per-command flags can force AI on or off. +- Repo-local defaults can enable AI behavior without requiring flags every time. +- PR titles remain commit-derived for squash-merge safety. +- DubStack-owned PR metadata remains deterministic and never AI-authored. + +This repo does not use git worktrees, and implementation must avoid git mutations such as `git add`, `git commit`, or `git push` unless the user explicitly requests them. + +## Goals + +- Add `--ai` and `--no-ai` control to `dub create`. +- Add `--ai` and `--no-ai` control to `dub submit`. +- Add repo-local defaults for AI behavior in create, submit, and flow. +- Add a new `dub flow` command that mirrors the existing `dub-flow` skill as a CLI workflow. +- Expand AI docs into a complete feature overview at `apps/docs/content/docs/guides/ai-assistant.mdx`. +- Update the bundled `skills/dubstack/SKILL.md` guidance so coding agents can configure and use the new AI behaviors. +- Plan a better terminal UI for `dub ai ask`. + +## Non-Goals + +- AI-generated PR titles. +- Replacing the existing stack table or hidden PR metadata blocks with AI content. +- Silent AI fallback when the user explicitly requested AI. +- Broad changes to non-AI DubStack workflows. + +## User Experience + +### Master AI Gate + +`dub config ai-assistant on|off` remains the master repo-local enablement switch. + +- If AI is explicitly requested while the assistant is disabled, the command fails with a clear `DubError`. +- If AI is not explicitly requested, commands resolve behavior through repo defaults. + +### Command-Level Precedence + +AI mode resolves in this order: + +1. Per-invocation flag +2. Repo-local default +3. Built-in default of `off` + +This gives users three ways to control behavior: + +- `--ai` forces AI on for that command. +- `--no-ai` forces AI off for that command. +- No flag uses the repo-local default. + +### `dub create` + +`dub create` continues to support manual branch creation and manual commit messages. AI behavior changes to tri-state resolution: + +- `dub create --ai` +- `dub create --no-ai` +- `dub create` with repo defaults + +When AI is active, DubStack generates: + +- branch name +- conventional commit subject + +Branch generation still requires staged changes. Existing validation remains: + +- branch name must be valid +- commit message must be a conventional commit subject +- staged-change requirements for `-a`, `-u`, and `-p` remain explicit + +### `dub submit` + +`dub submit --ai` generates a PR description body only. + +It does not generate or rewrite the PR title. The title remains the last commit message so squash merges preserve a conventional commit title. + +When AI is active, submit generates a human-readable PR summary section using branch context such as: + +- branch name +- base branch +- commit message +- branch diff against parent + +DubStack then appends its existing deterministic sections: + +- stack table +- hidden metadata block + +For existing PRs, submit should update only the AI-managed summary section and preserve: + +- user-authored freeform content +- DubStack stack table +- DubStack metadata block + +### `dub flow` + +Add a new high-level command that packages the AI-assisted authoring workflow: + +- stage changes +- generate branch name +- generate commit message +- create branch and commit +- submit PRs +- generate PR description + +Recommended command forms: + +- `dub flow` +- `dub f` + +Avoid `dub -f` as a top-level command shortcut. In Commander, single-dash forms behave like options, not subcommands, and would create avoidable parsing ambiguity. + +Expected flags: + +- `-a, --all` +- `-u, --update` +- `-p, --patch` +- `-y, --yes` +- `--ai` +- `--no-ai` +- `--dry-run` + +Default behavior: + +- Requires AI to be enabled and available when AI mode resolves to on. +- Shows a preview of generated metadata before mutating anything. +- `-y` auto-approves the generated metadata and skips the confirmation prompt. + +Preview content: + +- proposed branch name +- proposed commit message +- proposed PR description summary +- exact DubStack commands that will be executed + +Previews should render markdown cleanly in the terminal so users can accurately review generated commit and PR content before approval. + +## Configuration Design + +Keep the existing `aiAssistantEnabled` boolean and extend the config with repo-local defaults: + +```json +{ + "aiAssistantEnabled": true, + "ai": { + "defaults": { + "createMetadata": false, + "submitDescription": false, + "flow": false + } + } +} +``` + +The `ai.defaults` values apply only when the command invocation does not specify `--ai` or `--no-ai`. + +## Internal Architecture + +### Shared AI Metadata Layer + +AI metadata generation is currently embedded in `packages/cli/src/commands/create.ts`. That logic should move into a shared library so multiple commands can reuse: + +- provider resolution +- prompt construction +- JSON parsing +- redaction/truncation +- validation + +Recommended new library area: + +- `packages/cli/src/lib/ai-metadata.ts` + +This helper should expose separate generation functions for: + +- create metadata +- PR description summary + +### Temporary File Editing and Apply Flow + +For editing generated commit messages and PR descriptions, prefer a file-backed flow over inline shell arguments. + +Use separate temporary markdown files for: + +- commit message content +- PR description content + +Recommended sequence: + +1. write generated content to temp files +2. open the appropriate file when the user chooses to edit +3. apply the edited content using the relevant `git` or `gh` file-based option +4. delete temp files in cleanup logic even on failure + +Examples: + +- commit message application should prefer a file-backed git flow such as `git commit --file ` +- PR description application should prefer `gh pr create --body-file ` or `gh pr edit --body-file ` + +This avoids brittle multiline shell escaping and gives users a reliable editing path. + +### PR Body Composition + +`packages/cli/src/lib/pr-body.ts` currently preserves user content while appending the DubStack stack table and metadata block. To support AI summaries safely, PR body composition should gain explicit markers for an AI-managed summary section. + +Suggested shape: + +- AI summary marker start/end +- helper to strip and replace only that section +- final composition order: + 1. preserved user content + 2. AI summary section + 3. DubStack stack table + 4. hidden metadata block + +This keeps deterministic DubStack sections isolated from generated text. + +### Config Command Surface + +Keep `dub config ai-assistant` as the master toggle and add focused configuration for defaults, for example: + +- `dub config ai-defaults create on|off` +- `dub config ai-defaults submit on|off` +- `dub config ai-defaults flow on|off` + +This is clearer than overloading `ai-assistant` with multiple meanings. + +## Documentation Changes + +Expand `apps/docs/content/docs/guides/ai-assistant.mdx` into the canonical AI overview page. It should intentionally duplicate key information from command reference pages. + +Sections to add: + +- setup and provider selection +- AI shortcut entry points + - `dub "prompt"` + - `dub --ai "prompt"` + - `dub ai ask` +- AI-assisted create + - `dub create --ai` + - `dub create --no-ai` +- AI-assisted submit + - `dub submit --ai` + - `dub submit --no-ai` +- end-to-end AI workflow + - `dub flow` + - `dub f` +- AI conflict resolution + - `dub ai resolve` + - `dub continue --ai` +- repo-local defaults and precedence +- safety guarantees and limitations + +Related docs that should be updated for consistency: + +- command reference pages for `create` and `submit` +- docs homepage and quickstart snippets that mention AI behavior +- any other docs pages that mention AI commands, AI flags, or AI capabilities so the site stays internally consistent + +This should be treated as a docs-site audit, not a single-page update. Any page that mentions: + +- `dub ai ask` +- `dub ai env` +- `dub config ai-assistant` +- `dub create --ai` +- `dub submit --ai` +- `--no-ai` +- AI conflict resolution +- `dub flow` + +should be reviewed and updated as needed. + +## Skill Updates + +Update `skills/dubstack/SKILL.md` so bundled coding agents know how to: + +- configure AI keys with `dub ai env` +- enable AI in a repo with `dub config ai-assistant on` +- use `dub create --ai`, `dub submit --ai`, and `--no-ai` +- use repo-local AI defaults +- use `dub flow` / `dub f` +- understand that PR titles stay commit-derived +- understand that PR descriptions are AI-generated but DubStack metadata remains deterministic +- find AI conflict-resolution commands + +The recommended workflow section should include both: + +- manual workflow +- AI-assisted workflow + +## Terminal UI Improvement Plan + +The current `dub ai ask` renderer is too raw for human use. Today it mostly streams plain text plus a carriage-return spinner preview. A phased improvement plan is appropriate. + +### Phase 1: Better Live Status and Markdown Preview + +- replace the current noisy thinking preview with concise status labels +- show tool activity explicitly +- introduce readable terminal markdown rendering for streamed AI responses and generated-content previews +- keep non-TTY output plain and stable + +### Phase 2: Rich Response Rendering and Approval UX + +- render headings, bullets, code fences, blockquotes, and tables more clearly +- improve spacing and section separation +- add light formatting with `chalk` +- use rendered markdown for approval screens that preview generated commit messages and PR descriptions + +### Phase 3: Shared Rich Preview UI + +- reuse terminal cards/previews for `dub create --ai`, `dub submit --ai`, and `dub flow` +- show approval screens with clean labels and clear next actions + +## Risks and Mitigations + +### Risk: AI body updates overwrite user-written PR content + +Mitigation: + +- use explicit AI summary markers +- preserve non-marked user content +- preserve DubStack-owned sections separately + +### Risk: Too many overlapping AI toggles become confusing + +Mitigation: + +- keep a simple precedence model +- use `--no-ai` consistently +- document examples in the AI guide and the bundled skill + +### Risk: `dub flow` duplicates existing commands without adding clarity + +Mitigation: + +- make it a thin orchestrator around `create` and `submit` +- keep lower-level commands unchanged and documented +- position it as the fastest path, not the only path + +## Rollout Order + +1. Config defaults and shared AI metadata helpers +2. `dub create` tri-state AI behavior +3. `dub submit` AI-generated PR description summaries +4. `dub flow` and `dub f` +5. Documentation and skill updates +6. Terminal UI improvements for `dub ai ask` diff --git a/docs/plans/2026-03-08-ai-assistant-metadata-flow.md b/docs/plans/2026-03-08-ai-assistant-metadata-flow.md new file mode 100644 index 0000000..099677b --- /dev/null +++ b/docs/plans/2026-03-08-ai-assistant-metadata-flow.md @@ -0,0 +1,527 @@ +# AI Assistant Metadata and Flow Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add repo-configurable AI metadata generation for `create`, `submit`, and a new `flow` command, then document and teach the workflow across the docs site and bundled skills. + +**Architecture:** Extend the existing repo-local AI config with per-command defaults, extract shared AI metadata generation into a reusable library, and keep `create`, `submit`, and `flow` as thin command surfaces over that shared logic. Preserve deterministic DubStack PR metadata while inserting AI-authored PR summaries via explicit body markers, then follow with docs, skill, and terminal-UX updates. + +**Tech Stack:** TypeScript, Commander, Vitest, AI SDK, `gh` CLI integration, Fumadocs MDX content + +--- + +## Implementation Notes + +- Work in the current checkout. This repo explicitly does not use git worktrees. +- Do not run `git add`, `git commit`, or `git push` unless the user explicitly asks. +- Follow TDD for each behavior change. +- After any code changes, run `pnpm checks`, `pnpm typecheck`, and `pnpm test` from `/Users/wise/dev/dubstack`. + +### Task 1: Add config support for AI defaults + +**Files:** +- Modify: `packages/cli/src/lib/config.ts` +- Modify: `packages/cli/src/lib/config.test.ts` +- Modify: `packages/cli/src/commands/config.ts` +- Modify: `packages/cli/src/commands/config.test.ts` +- Modify: `packages/cli/src/index.ts` + +**Step 1: Write the failing config-shape tests** + +Add tests that expect `readConfig()` and `writeConfig()` to round-trip: + +```ts +expect(config.ai.defaults).toEqual({ + createMetadata: false, + submitDescription: false, + flow: false, +}); +``` + +Add tests for missing and partial config data to verify normalization preserves defaults. + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/config.test.ts packages/cli/src/commands/config.test.ts +``` + +Expected: failing assertions because `ai.defaults` and new config commands do not exist yet. + +**Step 3: Add minimal config schema support** + +Implement `ai.defaults` in `packages/cli/src/lib/config.ts` and normalize booleans defensively: + +```ts +defaults: { + createMetadata: + typeof defaults?.createMetadata === 'boolean' + ? defaults.createMetadata + : false, + submitDescription: + typeof defaults?.submitDescription === 'boolean' + ? defaults.submitDescription + : false, + flow: + typeof defaults?.flow === 'boolean' ? defaults.flow : false, +} +``` + +**Step 4: Extend config commands** + +Add command helpers for: + +```ts +configAiDefaults(cwd, 'create' | 'submit' | 'flow', 'on' | 'off' | undefined) +``` + +Then wire new `dub config ai-defaults ...` subcommands in `packages/cli/src/index.ts`. + +**Step 5: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/config.test.ts packages/cli/src/commands/config.test.ts +``` + +Expected: PASS. + +### Task 2: Extract shared AI metadata helpers + +**Files:** +- Create: `packages/cli/src/lib/ai-metadata.ts` +- Create: `packages/cli/src/lib/ai-metadata.test.ts` +- Modify: `packages/cli/src/commands/create.ts` + +**Step 1: Write failing tests for provider resolution and create metadata parsing** + +Add tests that cover: + +- Gemini provider selection +- Gateway fallback +- missing-key failure +- create metadata JSON parsing and validation + +Example expectations: + +```ts +await expect(generateCreateMetadata(diff, deps)).resolves.toEqual({ + branch: 'feat/example', + message: 'feat: example', +}); +``` + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/ai-metadata.test.ts packages/cli/src/commands/create.test.ts +``` + +Expected: module-not-found or failing expectations. + +**Step 3: Move create AI logic into the shared helper** + +Extract: + +- model resolution +- prompt building +- JSON extraction +- conventional commit validation +- diff redaction and truncation + +Keep `create.ts` focused on command behavior: + +```ts +const generated = await generateCreateMetadata(stagedDiff, deps); +branchName = generated.branch; +commitMessage = generated.message; +``` + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/ai-metadata.test.ts packages/cli/src/commands/create.test.ts +``` + +Expected: PASS. + +### Task 3: Add tri-state AI behavior to `dub create` + +**Files:** +- Modify: `packages/cli/src/commands/create.ts` +- Modify: `packages/cli/src/commands/create.test.ts` +- Modify: `packages/cli/src/index.ts` +- Modify: `apps/docs/content/docs/commands/create.mdx` + +**Step 1: Write failing command tests for `--no-ai` and repo defaults** + +Add tests for: + +- repo default enables AI when no flag is passed +- `--no-ai` disables AI even when repo default is on +- `--ai` overrides repo default off +- manual `-m` still conflicts with forced AI + +Example: + +```ts +await create(undefined as unknown as string, dir, { noAi: true }); +``` + +should fail with the manual-mode branch-name requirement instead of calling AI. + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/commands/create.test.ts +``` + +Expected: failing expectations because tri-state AI resolution does not exist. + +**Step 3: Implement tri-state option resolution** + +Add command options for: + +```ts +ai?: boolean; +noAi?: boolean; +``` + +Then resolve AI mode with: + +```ts +const useAi = + options.ai === true ? true + : options.noAi === true ? false + : config.ai.defaults.createMetadata; +``` + +Reject invalid combinations such as `--ai` with `--no-ai`. + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/commands/create.test.ts +``` + +Expected: PASS. + +### Task 4: Add AI-managed PR description support to `submit` + +**Files:** +- Modify: `packages/cli/src/commands/submit.ts` +- Modify: `packages/cli/src/commands/submit.test.ts` +- Modify: `packages/cli/src/lib/pr-body.ts` +- Modify: `packages/cli/src/lib/pr-body.test.ts` +- Modify: `packages/cli/src/lib/ai-metadata.ts` + +**Step 1: Write failing tests for AI PR summary composition** + +Add `pr-body` tests that verify: + +- AI summary sections can be inserted +- existing AI summary sections can be replaced +- user-written content is preserved +- DubStack metadata blocks remain intact + +Add submit tests that verify: + +- `submit --ai` requests a generated PR summary +- PR titles still come from `getLastCommitMessage` +- existing PRs get updated bodies rather than rewritten titles + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/pr-body.test.ts packages/cli/src/commands/submit.test.ts +``` + +Expected: failing expectations because AI body markers and submit AI generation do not exist. + +**Step 3: Implement AI summary markers and composer helpers** + +Extend `pr-body.ts` with helpers like: + +```ts +buildAiSummarySection(summary: string): string +stripAiSummarySection(body: string): string +composePrBody(existingBody, aiSummary, stackTable, metadataBlock): string +``` + +Use explicit markers so only the AI-managed summary is replaced. + +**Step 4: Implement submit AI mode** + +Add tri-state AI resolution to `submit`, then call a shared helper such as: + +```ts +const summary = await generatePrDescriptionSummary(context, deps); +``` + +Use diff-vs-parent, branch name, base branch, and commit title as prompt context. + +If the user chooses to edit generated PR text, write it to a temporary markdown file, apply it through a file-backed `gh` command, and clean up the temp file afterward. + +**Step 5: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/pr-body.test.ts packages/cli/src/commands/submit.test.ts +``` + +Expected: PASS. + +### Task 5: Add `dub flow` and `dub f` + +**Files:** +- Create: `packages/cli/src/commands/flow.ts` +- Create: `packages/cli/src/commands/flow.test.ts` +- Modify: `packages/cli/src/index.ts` +- Modify: `packages/cli/src/commands/create.ts` +- Modify: `packages/cli/src/commands/submit.ts` + +**Step 1: Write failing tests for flow orchestration** + +Add tests that verify: + +- flow stages changes according to `-a`, `-u`, or `-p` +- flow previews AI metadata before mutating +- `-y` skips confirmation +- flow can route generated commit and PR text through separate temp markdown files for editing +- flow calls create and submit in sequence +- `dub f` works as an alias + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/commands/flow.test.ts +``` + +Expected: failing expectations because the command does not exist. + +**Step 3: Implement minimal orchestration** + +Create a thin command wrapper that: + +1. resolves AI mode +2. stages changes +3. generates metadata preview +4. renders the preview with terminal markdown +5. prompts unless `-y` +6. optionally writes commit and PR text to separate temp markdown files for editing +7. delegates to `create` +8. delegates to `submit` + +Keep business rules in reusable helpers rather than inside `index.ts`. + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/commands/flow.test.ts +``` + +Expected: PASS. + +### Task 6: Expand docs and bundled skills + +**Files:** +- Modify: `apps/docs/content/docs/guides/ai-assistant.mdx` +- Modify: `apps/docs/content/docs/commands/create.mdx` +- Modify: `apps/docs/content/docs/commands/submit.mdx` +- Modify: `apps/docs/content/docs/index.mdx` +- Modify: `apps/docs/content/docs/getting-started/quickstart.mdx` +- Modify: `skills/dubstack/SKILL.md` +- Modify: `skills/dub-flow/SKILL.md` +- Audit and update any additional docs pages under `apps/docs/content/docs/` that mention AI commands, AI flags, or AI capabilities + +**Step 1: Write the docs changes directly from the approved design** + +Update the AI guide so it becomes the complete AI overview page, including: + +- setup +- `dub ai ask` +- AI shortcut usage +- `dub create --ai` +- `dub submit --ai` +- `dub flow` +- repo defaults and precedence +- AI conflict resolution + +Update the bundled skills so agents learn the new setup and workflow. + +Treat this as a site-wide consistency pass. Do not stop after the obvious pages above if other docs still mention old AI behavior. + +**Step 2: Verify docs and skill content by inspection** + +Check that examples are internally consistent and reflect the final CLI syntax: + +```bash +rg -n -- 'create --ai|submit --ai|--no-ai|dub flow|dub f|ai-defaults' \ + apps/docs/content/docs skills +``` + +Expected: all new syntax appears consistently, and stale wording is removed. + +Then run a broader audit for any remaining AI references: + +```bash +rg -n -- 'dub ai|ai-assistant|--ai|--no-ai|AI conflict|branch naming|commit messages|PR description' \ + apps/docs/content/docs skills +``` + +Expected: every remaining mention of AI behavior is either still correct or updated in the same change. + +### Task 7: Improve `dub ai ask` terminal UX + +**Files:** +- Modify: `packages/cli/src/commands/ai.ts` +- Modify: `packages/cli/src/commands/ai.test.ts` +- Create: `packages/cli/src/lib/terminal-render.ts` +- Create: `packages/cli/src/lib/terminal-render.test.ts` + +**Step 1: Write failing renderer tests** + +Cover: + +- TTY status-line rendering +- plain-mode stability for non-TTY output +- readable formatting for headings, bullets, and code fences +- readable formatting for blockquotes and tables +- explicit tool activity lines +- rendered previews for approval flows that include commit messages and PR descriptions + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/terminal-render.test.ts packages/cli/src/commands/ai.test.ts +``` + +Expected: failing expectations because the richer renderer does not exist. + +**Step 3: Implement a shared terminal renderer** + +Extract renderer logic from `ai.ts` and support: + +- concise live status labels +- visible tool activity events +- lightweight markdown rendering suitable for streaming and previews +- plain fallback for non-TTY output + +Keep reasoning hidden by default and do not stream noisy internal previews. + +Use the renderer in both: + +- `dub ai ask` +- approval and confirmation flows for generated commit messages and PR descriptions + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/terminal-render.test.ts packages/cli/src/commands/ai.test.ts +``` + +Expected: PASS. + +### Task 8: Add temp-file helpers for generated text editing + +**Files:** +- Create: `packages/cli/src/lib/temp-text-file.ts` +- Create: `packages/cli/src/lib/temp-text-file.test.ts` +- Modify: `packages/cli/src/lib/git.ts` +- Modify: `packages/cli/src/lib/github.ts` +- Modify: `packages/cli/src/commands/flow.ts` +- Modify: `packages/cli/src/commands/create.ts` +- Modify: `packages/cli/src/commands/submit.ts` + +**Step 1: Write failing tests for temp-file lifecycle** + +Cover: + +- writes separate temp markdown files for commit and PR text +- applies file-backed content through git or gh helpers +- always cleans up temp files, even on failure + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/temp-text-file.test.ts +``` + +Expected: failing expectations because the helper does not exist. + +**Step 3: Implement the helper and file-backed apply paths** + +Add a helper that: + +- creates temp markdown files with predictable prefixes +- returns the path +- deletes files in cleanup logic + +Then expose file-backed helpers such as: + +```ts +commitStagedFromFile(filePath, cwd) +createPr(..., bodyFile, cwd) +updatePrBody(..., bodyFile, cwd) +``` + +Prefer `git commit --file ` and `gh pr ... --body-file ` over inline multiline arguments. + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/lib/temp-text-file.test.ts +``` + +Expected: PASS. + +### Task 9: Run full verification + +**Files:** +- No file edits + +**Step 1: Run repo quality gates** + +Run: + +```bash +pnpm checks +pnpm typecheck +pnpm test +``` + +Expected: all PASS. + +**Step 2: Record any failures before further edits** + +If a command fails, capture the exact file and assertion/type/lint error before making follow-up changes. + +**Step 3: Re-run the full suite after fixes** + +Run the same three commands again until the repo is green. diff --git a/evalite.config.ts b/evalite.config.ts new file mode 100644 index 0000000..f0fe8ce --- /dev/null +++ b/evalite.config.ts @@ -0,0 +1,17 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { DB_LOCATION } from 'evalite/backend-only-constants'; +import { defineConfig } from 'evalite/config'; +import { createSqliteStorage } from 'evalite/sqlite-storage'; + +const databasePath = path.resolve(DB_LOCATION); + +export default defineConfig({ + storage: async () => { + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + return createSqliteStorage(databasePath); + }, + scoreThreshold: 80, + maxConcurrency: 1, + testTimeout: 120_000, +}); diff --git a/package.json b/package.json index 52d854e..48ca4f0 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,22 @@ }, "scripts": { "build": "turbo run build", + "check:all": "pnpm checks:fix:unsafe && pnpm typecheck && pnpm test && pnpm evals", + "cli:dev": "pnpm --filter dubstack exec tsx src/index.ts", "test": "turbo run test", "test:coverage": "turbo run test:coverage", "typecheck": "turbo run typecheck", "checks": "biome check .", "checks:fix": "biome check --write .", "checks:fix:unsafe": "biome check --write --unsafe .", - "dev": "turbo run dev" + "dev": "turbo run dev", + "evals": "evalite run", + "evals:watch": "evalite watch", + "evals:export": "evalite export --output .evalite-export" }, "devDependencies": { "@biomejs/biome": "^2.4.2", + "evalite": "0.19.0", "turbo": "^2.5.0" } } diff --git a/packages/cli/evals/dub-flow-metadata.eval.ts b/packages/cli/evals/dub-flow-metadata.eval.ts new file mode 100644 index 0000000..c7dda2d --- /dev/null +++ b/packages/cli/evals/dub-flow-metadata.eval.ts @@ -0,0 +1,623 @@ +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createGateway, generateText, type LanguageModel } from 'ai'; +import { createScorer, evalite } from 'evalite'; +import { buildAiDiffContext } from '../src/lib/ai-diff-context'; +import { + type AiMetadataDependencies, + generateFlowMetadata, +} from '../src/lib/ai-metadata'; + +interface FlowEvalInput { + name: string; + parentBranch: string; + stagedDiff: string; + commitTemplate?: string | null; + prTemplate?: string | null; + unrelatedWorkingTreeNoise?: string; +} + +interface FlowEvalExpected { + summary: string; + branchPrefix: string; + requiredKeywords: string[]; + forbiddenKeywords?: string[]; + headlineKeywords?: string[]; + headlineForbiddenKeywords?: string[]; + commitTemplateHeadings?: string[]; + prTemplateHeadings?: string[]; +} + +interface FlowEvalOutput { + branch: string; + commitMessage: string; + prDescription: string; +} + +const LARGE_MIXED_MONOREPO_DIFF = `diff --git a/.agents/skills/beads/SKILL.md b/.agents/skills/beads/SKILL.md +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/.agents/skills/beads/SKILL.md +@@ -0,0 +1,40 @@ ++# Beads skill ++Use bd ready --json +diff --git a/.agents/skills/dub-flow-evals/SKILL.md b/.agents/skills/dub-flow-evals/SKILL.md +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/.agents/skills/dub-flow-evals/SKILL.md +@@ -0,0 +1,40 @@ ++# Flow evals skill ++Run pnpm evals +diff --git a/README.md b/README.md +index 1111111..2222222 100644 +--- a/README.md ++++ b/README.md +@@ -390,3 +390,25 @@ ++### dub flow ++Generates metadata from staged changes. +diff --git a/QUICKSTART.md b/QUICKSTART.md +index 1111111..2222222 100644 +--- a/QUICKSTART.md ++++ b/QUICKSTART.md +@@ -160,3 +160,20 @@ ++dub flow --ai -a +diff --git a/packages/cli/src/lib/ai-diff-context.ts b/packages/cli/src/lib/ai-diff-context.ts +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/packages/cli/src/lib/ai-diff-context.ts +@@ -0,0 +1,60 @@ ++export function buildAiDiffContext() { ++ return { ++ dominantCategory: 'runtime', ++ promptPacket: 'structured context', ++ }; ++} +diff --git a/packages/cli/src/lib/ai-metadata.ts b/packages/cli/src/lib/ai-metadata.ts +index 3333333..4444444 100644 +--- a/packages/cli/src/lib/ai-metadata.ts ++++ b/packages/cli/src/lib/ai-metadata.ts +@@ -1,8 +1,30 @@ ++import { buildAiDiffContext } from './ai-diff-context'; ++const prompt = [ ++ 'Consider the entire staged change set.', ++ 'Choose the headline from the dominant implementation change.', ++]; +diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 5555555..6666666 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -140,6 +140,25 @@ export async function flow( ++ const stagedFiles = await getDiffFileNames(cwd, true); ++ const stagedDiffStats = await getDiffNumStat(cwd, true); ++ const staged = buildAiDiffContext({ ++ rawDiff: stagedDiff, ++ filePaths: stagedFiles, ++ diffStats: stagedDiffStats, ++ }); ++ const generated = await generateFlowMetadata({ parentBranch, staged }, deps); +diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts +index 7777777..8888888 100644 +--- a/packages/cli/src/commands/flow.test.ts ++++ b/packages/cli/src/commands/flow.test.ts +@@ -1,3 +1,12 @@ ++it('collects structured staged context before asking AI', () => { ++ expect(getDiffFileNames).toHaveBeenCalled(); ++ expect(getDiffNumStat).toHaveBeenCalled(); ++}); +diff --git a/packages/cli/evals/dub-flow-metadata.eval.ts b/packages/cli/evals/dub-flow-metadata.eval.ts +index 9999999..aaaaaaa 100644 +--- a/packages/cli/evals/dub-flow-metadata.eval.ts ++++ b/packages/cli/evals/dub-flow-metadata.eval.ts +@@ -1,5 +1,16 @@ ++const mixedCase = 'large mixed monorepo diff'; ++const rubric = 'penalize docs-first headlines when runtime changed'; +`; + +const FLOW_CASES: Array<{ + input: FlowEvalInput; + expected: FlowEvalExpected; +}> = [ + { + input: { + name: 'simple feature diff', + parentBranch: 'main', + stagedDiff: `diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/packages/cli/src/commands/flow.ts +@@ -0,0 +1,22 @@ ++export async function previewFlow() { ++ renderPreview('Branch Name', 'feat/preview-flow'); ++ renderPreview('PR Description', 'Shows generated metadata before mutation.'); ++} +diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts +new file mode 100644 +index 0000000..2222222 +--- /dev/null ++++ b/packages/cli/src/commands/flow.test.ts +@@ -0,0 +1,8 @@ ++it('previews generated flow metadata', () => { ++ expect(renderPreview).toHaveBeenCalled(); ++}); +`, + }, + expected: { + summary: 'Adds the new preview-first AI flow command.', + branchPrefix: 'feat/', + requiredKeywords: ['flow', 'preview'], + }, + }, + { + input: { + name: 'bug fix diff', + parentBranch: 'main', + stagedDiff: `diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/submit.ts ++++ b/packages/cli/src/commands/submit.ts +@@ -430,7 +430,11 @@ async function getDiffForPrDescription( +- return getDiff(cwd, false); ++ return getDiffBetween(baseBranch, branchName, cwd); +diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts +index 3333333..4444444 100644 +--- a/packages/cli/src/lib/git.ts ++++ b/packages/cli/src/lib/git.ts +@@ -470,6 +470,12 @@ export async function getDiffBetween( +- return ''; ++ throw new DubError( ++ \`Failed to diff '\${head}' against '\${base}'. Verify both refs exist and are reachable.\`, ++ ); +`, + }, + expected: { + summary: + 'Fixes submit AI summaries so they use the branch diff instead of local working tree noise.', + branchPrefix: 'fix/', + requiredKeywords: ['diff', 'submit', 'branch'], + forbiddenKeywords: ['working tree'], + }, + }, + { + input: { + name: 'commit template case', + parentBranch: 'main', + commitTemplate: `feat(scope): summary + +## Testing +- [ ] added coverage + +## Rollout +- [ ] safe to enable`, + stagedDiff: `diff --git a/packages/cli/src/lib/metadata-templates.ts b/packages/cli/src/lib/metadata-templates.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/lib/metadata-templates.ts ++++ b/packages/cli/src/lib/metadata-templates.ts +@@ -1,6 +1,18 @@ ++export async function readMetadataTemplates() { ++ return { ++ commitTemplate: fs.readFileSync('.gitmessage', 'utf8'), ++ prTemplate: null, ++ }; ++} +`, + }, + expected: { + summary: + 'Preserves the repository commit template in generated flow commits.', + branchPrefix: 'feat/', + requiredKeywords: ['template', 'commit'], + commitTemplateHeadings: ['## Testing', '## Rollout'], + }, + }, + { + input: { + name: 'pr template case', + parentBranch: 'main', + prTemplate: `## Summary + +## Testing + +## Risks`, + stagedDiff: `diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -120,6 +120,16 @@ export async function flow( ++ renderPreview('PR Description', prDescription); ++ renderPreview('Planned Commands', plannedCommands); ++ if (options.yes) { ++ return submit(cwd, false, { ++ path: 'current', ++ fix: false, ++ summaryOverrides: new Map([[generated.branch, prDescription]]), ++ }); ++ } +`, + }, + expected: { + summary: + 'Preserves the PR template while previewing and submitting flow content.', + branchPrefix: 'feat/', + requiredKeywords: ['template', 'preview', 'submit'], + prTemplateHeadings: ['## Summary', '## Testing', '## Risks'], + }, + }, + { + input: { + name: 'noisy tests and docs diff', + parentBranch: 'main', + stagedDiff: `diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -1,5 +1,14 @@ ++const summaryOverrides = new Map([[generated.branch, prDescription]]); ++await submit(cwd, false, { path: 'current', fix: false, summaryOverrides }); +diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts +index 3333333..4444444 100644 +--- a/packages/cli/src/commands/flow.test.ts ++++ b/packages/cli/src/commands/flow.test.ts +@@ -1,4 +1,10 @@ ++it('passes summary overrides to submit', () => { ++ expect(submit).toHaveBeenCalled(); ++}); +diff --git a/README.md b/README.md +index 5555555..6666666 100644 +--- a/README.md ++++ b/README.md +@@ -397,3 +397,8 @@ ++### dub flow ++Generates branch, commit, and PR content from staged changes. +`, + }, + expected: { + summary: + 'Captures the main flow behavior even when the diff also includes tests and documentation updates.', + branchPrefix: 'feat/', + requiredKeywords: ['flow', 'submit', 'summary'], + }, + }, + { + input: { + name: 'ignore unrelated local noise', + parentBranch: 'main', + stagedDiff: `diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/lib/auth.ts ++++ b/packages/cli/src/lib/auth.ts +@@ -1,5 +1,14 @@ ++export function redactToken(token: string): string { ++ return token.slice(0, 4) + '…'; ++} +diff --git a/packages/cli/src/lib/auth.test.ts b/packages/cli/src/lib/auth.test.ts +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/packages/cli/src/lib/auth.test.ts +@@ -0,0 +1,6 @@ ++it('redacts api tokens in logs', () => { ++ expect(redactToken('secret-token')).toBe('secr…'); ++}); +`, + unrelatedWorkingTreeNoise: + 'A separate unstaged billing CSV exporter refactor should not appear in the generated metadata.', + }, + expected: { + summary: + 'Focuses on auth token redaction and ignores unrelated unstaged billing work.', + branchPrefix: 'feat/', + requiredKeywords: ['auth', 'token'], + forbiddenKeywords: ['billing', 'csv', 'exporter'], + }, + }, + { + input: { + name: 'large mixed monorepo diff', + parentBranch: 'main', + stagedDiff: LARGE_MIXED_MONOREPO_DIFF, + }, + expected: { + summary: + 'Improves dub flow metadata quality by building structured staged context and using that to drive AI generation.', + branchPrefix: 'feat/', + requiredKeywords: ['flow', 'context'], + headlineKeywords: ['flow', 'context'], + headlineForbiddenKeywords: ['beads', 'skill', 'readme'], + }, + }, +]; + +evalite('dub flow metadata generation', { + data: FLOW_CASES, + task: async (input): Promise => { + return generateFlowMetadata( + { + parentBranch: input.parentBranch, + staged: buildAiDiffContext({ rawDiff: input.stagedDiff }), + }, + createEvalDependencies(), + { + commitTemplate: input.commitTemplate, + prTemplate: input.prTemplate, + }, + ); + }, + scorers: [ + createScorer({ + name: 'branch-contract', + description: + 'Branch names stay lowercase, slash-delimited, and match the expected prefix.', + scorer: ({ output, expected }) => { + const valid = + output.branch.startsWith(expected?.branchPrefix ?? '') && + /^[a-z0-9]+(?:[/-][a-z0-9]+)*$/.test(output.branch); + return { + score: valid ? 1 : 0, + metadata: { + branch: output.branch, + expectedPrefix: expected?.branchPrefix, + }, + }; + }, + }), + createScorer({ + name: 'commit-contract', + description: + 'Commit messages use a conventional subject and preserve commit template headings when present.', + scorer: ({ output, expected }) => { + const subject = output.commitMessage.split('\n')[0]?.trim() ?? ''; + const conventional = + /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test( + subject, + ); + const headings = expected?.commitTemplateHeadings ?? []; + const preserved = headings.every((heading) => + output.commitMessage.includes(heading), + ); + const fenceFree = !output.commitMessage.includes('```'); + return { + score: conventional && preserved && fenceFree ? 1 : 0, + metadata: { subject, headings }, + }; + }, + }), + createScorer({ + name: 'pr-contract', + description: + 'PR descriptions stay readable and preserve PR template heading order when present.', + scorer: ({ output, expected }) => { + if (output.prDescription.trim().length === 0) { + return { score: 0, metadata: { reason: 'empty' } }; + } + if (output.prDescription.includes('```')) { + return { score: 0, metadata: { reason: 'markdown-fence' } }; + } + const headings = expected?.prTemplateHeadings ?? []; + let cursor = 0; + const ordered = headings.every((heading) => { + const index = output.prDescription.indexOf(heading, cursor); + if (index === -1) return false; + cursor = index + heading.length; + return true; + }); + return { + score: ordered ? 1 : 0, + metadata: { headings }, + }; + }, + }), + createScorer({ + name: 'content-focus', + description: + 'Generated metadata emphasizes the actual diff and avoids unrelated terms.', + scorer: ({ output, expected }) => { + const haystack = [ + output.branch, + output.commitMessage, + output.prDescription, + ] + .join('\n') + .toLowerCase(); + const required = expected?.requiredKeywords ?? []; + const forbidden = expected?.forbiddenKeywords ?? []; + const matchedRequired = required.filter((keyword) => + haystack.includes(keyword.toLowerCase()), + ); + const matchedForbidden = forbidden.filter((keyword) => + haystack.includes(keyword.toLowerCase()), + ); + const requiredScore = + required.length === 0 ? 1 : matchedRequired.length / required.length; + const forbiddenPenalty = + forbidden.length === 0 ? 1 : matchedForbidden.length === 0 ? 1 : 0; + return { + score: requiredScore * forbiddenPenalty, + metadata: { + matchedRequired, + matchedForbidden, + }, + }; + }, + }), + createScorer({ + name: 'headline-focus', + description: + 'Branch and commit headlines reflect the dominant implementation change instead of whichever files appear first.', + scorer: ({ output, expected }) => { + const headline = [ + output.branch, + output.commitMessage.split('\n')[0] ?? '', + ] + .join('\n') + .toLowerCase(); + const required = expected?.headlineKeywords ?? []; + const forbidden = expected?.headlineForbiddenKeywords ?? []; + const matchedRequired = required.filter((keyword) => + headline.includes(keyword.toLowerCase()), + ); + const matchedForbidden = forbidden.filter((keyword) => + headline.includes(keyword.toLowerCase()), + ); + const requiredScore = + required.length === 0 ? 1 : matchedRequired.length / required.length; + const forbiddenPenalty = + forbidden.length === 0 ? 1 : matchedForbidden.length === 0 ? 1 : 0; + return { + score: requiredScore * forbiddenPenalty, + metadata: { + matchedRequired, + matchedForbidden, + }, + }; + }, + }), + createScorer({ + name: 'ai-judge', + description: + 'Judges whether the branch, commit, and PR text are faithful to the diff, useful to reviewers, and template-compliant.', + scorer: async ({ input, output, expected }) => { + const model = resolveEvalJudgeModel(); + const response = await generateText({ + model, + system: + 'You are grading git metadata quality for a CLI workflow. Return strict JSON only.', + prompt: [ + 'Evaluate the generated branch name, commit message, and PR description.', + 'Score from 0 to 100.', + 'Rubric:', + '- Faithful to the staged diff and parent branch', + '- Useful and concise for a reviewer', + '- Branch and commit headline should follow the dominant implementation change when runtime code changed', + '- Preserves commit/PR template structure when provided', + '- Avoids unrelated work or invented claims', + '- Penalize summaries that overfit whichever files appear first in git diff output', + 'Return JSON exactly like {"score":87,"rationale":"..."}', + '', + `Case: ${input.name}`, + `Expected intent: ${expected?.summary ?? ''}`, + '', + 'STAGED_DIFF_START', + input.stagedDiff, + 'STAGED_DIFF_END', + '', + 'COMMIT_TEMPLATE_START', + input.commitTemplate?.trim() || '[none]', + 'COMMIT_TEMPLATE_END', + '', + 'PR_TEMPLATE_START', + input.prTemplate?.trim() || '[none]', + 'PR_TEMPLATE_END', + '', + 'UNRELATED_NOISE_START', + input.unrelatedWorkingTreeNoise?.trim() || '[none]', + 'UNRELATED_NOISE_END', + '', + 'GENERATED_BRANCH_START', + output.branch, + 'GENERATED_BRANCH_END', + '', + 'GENERATED_COMMIT_START', + output.commitMessage, + 'GENERATED_COMMIT_END', + '', + 'GENERATED_PR_START', + output.prDescription, + 'GENERATED_PR_END', + ].join('\n'), + }); + + const parsed = parseJudgeResponse(response.text); + return { + score: parsed.score / 100, + metadata: { rationale: parsed.rationale }, + }; + }, + }), + ], + columns: ({ input, output, scores }) => [ + { label: 'Case', value: input.name }, + { label: 'Branch', value: output.branch }, + { + label: 'Commit', + value: output.commitMessage.split('\n')[0] ?? '', + }, + { + label: 'Avg', + value: + scores.length === 0 + ? 'n/a' + : Math.round( + (scores.reduce((sum, score) => sum + (score.score ?? 0), 0) / + scores.length) * + 100, + ), + }, + ], +}); + +function createEvalDependencies(): AiMetadataDependencies { + return { + generateText, + createGoogleGenerativeAI, + createGateway, + }; +} + +function resolveEvalJudgeModel(): LanguageModel { + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (geminiApiKey) { + const modelId = + process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; + const google = createGoogleGenerativeAI({ apiKey: geminiApiKey }); + return google(modelId); + } + + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (gatewayApiKey) { + const modelId = + process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; + const gateway = createGateway({ apiKey: gatewayApiKey }); + return gateway(modelId); + } + + throw new Error( + 'Evalite requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY.', + ); +} + +function parseJudgeResponse(text: string): { + score: number; + rationale: string; +} { + const match = text.trim().match(/\{[\s\S]*\}/); + if (!match) { + return { + score: 0, + rationale: `Judge returned non-JSON output: ${text.slice(0, 200)}`, + }; + } + + try { + const parsed = JSON.parse(match[0]) as { + score?: unknown; + rationale?: unknown; + }; + const score = + typeof parsed.score === 'number' + ? Math.max(0, Math.min(100, parsed.score)) + : 0; + const rationale = + typeof parsed.rationale === 'string' + ? parsed.rationale + : 'Judge did not provide a rationale.'; + return { score, rationale }; + } catch { + return { + score: 0, + rationale: `Judge returned invalid JSON: ${text.slice(0, 200)}`, + }; + } +} diff --git a/packages/cli/src/commands/ai.test.ts b/packages/cli/src/commands/ai.test.ts index 99c8816..d9ec906 100644 --- a/packages/cli/src/commands/ai.test.ts +++ b/packages/cli/src/commands/ai.test.ts @@ -32,6 +32,9 @@ function fullStreamFrom( | { type: 'reasoning-start' } | { type: 'reasoning-delta'; text: string } | { type: 'reasoning-end' } + | { type: 'tool-input-start'; toolName: string } + | { type: 'tool-input-delta'; toolName: string; text: string } + | { type: 'tool-input-end'; toolName: string } | { type: 'error'; error: unknown } >, ) { @@ -246,17 +249,67 @@ describe('askAi', () => { expect(result.modelId).toBe('google/gemini-2.5-pro'); }); - it('streams a TTY thinking preview with spinner frames', async () => { + it('streams text output as chunks arrive while still showing TTY status lines', async () => { + await writeConfig({ aiAssistantEnabled: true }, dir); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; + + const output = createOutputCapture({ isTTY: true }); + const writesAfterFirstChunk: string[] = []; + const streamText = vi.fn().mockReturnValue({ + fullStream: { + async *[Symbol.asyncIterator]() { + yield { type: 'reasoning-start' } as const; + yield { type: 'reasoning-delta', text: 'Planning edits' } as const; + yield { + type: 'text-delta', + text: '# Summary\n', + } as const; + writesAfterFirstChunk.push(output.writes.join('')); + yield { type: 'text-delta', text: '\n- Done.' } as const; + yield { type: 'reasoning-end' } as const; + }, + }, + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + const collectAiContext = vi.fn().mockResolvedValue(fakeContext); + const { createBashTool } = createBashToolMock(); + + await askAi('Explain this stack', dir, { + output: output.stream, + deps: { + streamText, + createGoogleGenerativeAI, + createGateway, + collectAiContext, + createBashTool, + }, + }); + + const rendered = output.writes.join(''); + expect(rendered).toContain('AI: thinking'); + expect(rendered).not.toContain('\r'); + expect(writesAfterFirstChunk[0]).toContain('# Summary'); + expect(rendered).toContain('# Summary'); + expect(rendered).toContain('- Done.'); + }); + + it('prints explicit tool activity lines in TTY mode', async () => { await writeConfig({ aiAssistantEnabled: true }, dir); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; const streamText = vi.fn().mockReturnValue({ fullStream: fullStreamFrom([ - { type: 'reasoning-start' }, - { type: 'reasoning-delta', text: 'Planning edits' }, - { type: 'reasoning-delta', text: ' and checks' }, - { type: 'reasoning-end' }, + { type: 'tool-input-start', toolName: 'bash' }, + { + type: 'tool-input-delta', + toolName: 'bash', + text: 'git status --short', + }, + { type: 'tool-input-end', toolName: 'bash' }, { type: 'text-delta', text: 'Done.' }, ]), }); @@ -279,9 +332,8 @@ describe('askAi', () => { }); const rendered = output.writes.join(''); - expect(rendered).toContain('thinking:'); - expect(rendered).toContain('\r'); - expect(rendered.endsWith('Done.\n')).toBe(true); + expect(rendered).toContain('AI: running bash'); + expect(rendered).toContain('git status --short'); }); it('throws when the stream emits an error part', async () => { diff --git a/packages/cli/src/commands/ai.ts b/packages/cli/src/commands/ai.ts index 37ba609..0e93649 100644 --- a/packages/cli/src/commands/ai.ts +++ b/packages/cli/src/commands/ai.ts @@ -10,6 +10,7 @@ import { } from '../lib/ai-context'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; +import { createTerminalRenderer } from '../lib/terminal-render'; interface WritableLike { write: (chunk: string | Uint8Array) => unknown; @@ -53,8 +54,6 @@ const THINKING_PROVIDER_OPTIONS = { }, } as const; -const SPINNER_FRAMES = ['-', '\\', '|', '/'] as const; - export async function askAi( prompt: string, cwd: string, @@ -86,7 +85,6 @@ export async function askAi( const webBrowsingRequested = config.ai.webBrowsing.mode === 'model-native'; let webBrowsingUsed = webBrowsingRequested; - let wroteOutput = false; const runStream = async (withWebBrowsing: boolean): Promise => { const result = deps.streamText({ model: resolved.model, @@ -102,7 +100,7 @@ export async function askAi( }; try { - wroteOutput = await runStream(webBrowsingRequested); + await runStream(webBrowsingRequested); } catch (error) { if (!isBrowsingUnsupportedError(error)) { throw error; @@ -114,11 +112,7 @@ export async function askAi( output.write( '[note] Web browsing is unavailable for this provider/model right now. Continuing with local context and model knowledge.\n', ); - wroteOutput = await runStream(false); - } - - if (wroteOutput) { - output.write('\n'); + await runStream(false); } return { @@ -146,32 +140,70 @@ async function renderStream( fullStream: AsyncIterable<{ type: string; text?: string; + toolName?: string; error?: unknown; }>; }, output: WritableLike, ): Promise { - const thinkingRenderer = createThinkingRenderer(output); + const renderer = createTerminalRenderer(output); let wroteOutput = false; + let endedWithNewline = false; + let pendingToolName: string | null = null; + let pendingToolDetail = ''; + + const flushPendingTool = () => { + if (!pendingToolName) return; + renderer.renderToolActivity(pendingToolName, pendingToolDetail); + pendingToolName = null; + pendingToolDetail = ''; + }; + try { for await (const part of result.fullStream) { switch (part.type) { case 'reasoning-start': { - thinkingRenderer.start(); + renderer.renderStatus('thinking'); break; } case 'reasoning-delta': { - thinkingRenderer.update(part.text ?? ''); break; } case 'reasoning-end': { - thinkingRenderer.stop(); + break; + } + case 'tool-input-start': { + flushPendingTool(); + pendingToolName = part.toolName ?? 'tool'; + pendingToolDetail = ''; + break; + } + case 'tool-input-delta': { + if (!pendingToolName) { + pendingToolName = part.toolName ?? 'tool'; + } + pendingToolDetail += part.text ?? ''; + break; + } + case 'tool-input-end': { + if (!pendingToolName) { + pendingToolName = part.toolName ?? 'tool'; + } + flushPendingTool(); + break; + } + case 'tool-call': { + renderer.renderToolActivity(part.toolName ?? 'tool', part.text); break; } case 'text-delta': { - thinkingRenderer.pauseForText(); - output.write(part.text ?? ''); - wroteOutput = true; + flushPendingTool(); + const chunk = part.text ?? ''; + if (chunk.length > 0) { + output.write(chunk); + wroteOutput = true; + endedWithNewline = chunk.endsWith('\n'); + } break; } case 'error': { @@ -185,8 +217,13 @@ async function renderStream( } } } finally { - thinkingRenderer.stop(); + flushPendingTool(); } + + if (wroteOutput && !endedWithNewline) { + output.write('\n'); + } + return wroteOutput; } @@ -232,65 +269,3 @@ function resolveModel(deps: AskAiDependencies): { "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", ); } - -function createThinkingRenderer(output: WritableLike): { - start: () => void; - update: (delta: string) => void; - pauseForText: () => void; - stop: () => void; -} { - if (!output.isTTY) { - return { - start() {}, - update() {}, - pauseForText() {}, - stop() {}, - }; - } - - let spinnerIndex = 0; - let preview = ''; - let lineLength = 0; - let active = false; - let hasRendered = false; - - const clearLine = () => { - if (!hasRendered) return; - output.write(`\r${' '.repeat(lineLength)}\r`); - lineLength = 0; - hasRendered = false; - }; - - const render = () => { - const frame = SPINNER_FRAMES[spinnerIndex]; - spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length; - - const summary = - preview.length > 96 ? `${preview.slice(0, 93)}...` : preview; - const line = `${frame} thinking: ${summary || 'working...'}`; - output.write(`\r${line}`); - lineLength = line.length; - hasRendered = true; - }; - - return { - start() { - if (active) return; - active = true; - render(); - }, - update(delta: string) { - if (!active) return; - preview += delta; - render(); - }, - pauseForText() { - clearLine(); - }, - stop() { - active = false; - preview = ''; - clearLine(); - }, - }; -} diff --git a/packages/cli/src/commands/config.test.ts b/packages/cli/src/commands/config.test.ts index a3a6816..4427ac8 100644 --- a/packages/cli/src/commands/config.test.ts +++ b/packages/cli/src/commands/config.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestRepo } from '../../test/helpers'; import { readConfig } from '../lib/config'; -import { configAiAssistant } from './config'; +import { configAiAssistant, configAiDefaults } from './config'; let dir: string; let cleanup: () => Promise; @@ -45,3 +45,40 @@ describe('config ai-assistant', () => { ); }); }); + +describe('config ai-defaults', () => { + it('returns current create default when state argument is omitted', async () => { + const result = await configAiDefaults(dir, 'create'); + + expect(result).toEqual({ enabled: false, changed: false }); + }); + + it('writes submit default when set to on', async () => { + const result = await configAiDefaults(dir, 'submit', 'on'); + const config = await readConfig(dir); + + expect(result).toEqual({ enabled: true, changed: true }); + expect(config.ai.defaults.submitDescription).toBe(true); + }); + + it('writes flow default when set to off', async () => { + await configAiDefaults(dir, 'flow', 'on'); + const result = await configAiDefaults(dir, 'flow', 'off'); + const config = await readConfig(dir); + + expect(result).toEqual({ enabled: false, changed: true }); + expect(config.ai.defaults.flow).toBe(false); + }); + + it('throws for invalid default targets', async () => { + await expect(configAiDefaults(dir, 'unknown' as never)).rejects.toThrow( + "Config target must be one of 'create', 'submit', or 'flow'.", + ); + }); + + it('throws for invalid default state values', async () => { + await expect(configAiDefaults(dir, 'create', 'maybe')).rejects.toThrow( + "Value must be either 'on' or 'off'.", + ); + }); +}); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index bc72769..bf6f348 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,15 +1,18 @@ +import type { DubConfig } from '../lib/config'; import { readConfig, writeConfig } from '../lib/config'; import { DubError } from '../lib/errors'; -export interface ConfigAiAssistantResult { +export interface ConfigBooleanResult { enabled: boolean; changed: boolean; } +export type AiDefaultTarget = 'create' | 'submit' | 'flow'; + export async function configAiAssistant( cwd: string, state?: string, -): Promise { +): Promise { const config = await readConfig(cwd); if (state == null) { return { @@ -36,8 +39,58 @@ export async function configAiAssistant( }; } +export async function configAiDefaults( + cwd: string, + target: AiDefaultTarget, + state?: string, +): Promise { + const config = await readConfig(cwd); + const key = resolveAiDefaultKey(target); + + if (state == null) { + return { + enabled: config.ai.defaults[key], + changed: false, + }; + } + + const parsed = parseAiAssistantState(state); + const changed = config.ai.defaults[key] !== parsed; + if (changed) { + await writeConfig( + { + ...config, + ai: { + ...config.ai, + defaults: { + ...config.ai.defaults, + [key]: parsed, + }, + }, + }, + cwd, + ); + } + + return { + enabled: parsed, + changed, + }; +} + function parseAiAssistantState(value: string): boolean { if (value === 'on') return true; if (value === 'off') return false; throw new DubError("Value must be either 'on' or 'off'."); } + +function resolveAiDefaultKey( + target: AiDefaultTarget, +): keyof DubConfig['ai']['defaults'] { + if (target === 'create') return 'createMetadata'; + if (target === 'submit') return 'submitDescription'; + if (target === 'flow') return 'flow'; + throw new DubError( + "Config target must be one of 'create', 'submit', or 'flow'.", + ); +} diff --git a/packages/cli/src/commands/create.test.ts b/packages/cli/src/commands/create.test.ts index 7213c32..4f2762b 100644 --- a/packages/cli/src/commands/create.test.ts +++ b/packages/cli/src/commands/create.test.ts @@ -192,6 +192,138 @@ describe('create with -u -m', () => { }); describe('create with --ai', () => { + it('uses the repo default to enable AI when no flag is passed', async () => { + await writeConfig( + { + aiAssistantEnabled: true, + ai: { + defaults: { + createMetadata: true, + submitDescription: false, + flow: false, + }, + }, + }, + dir, + ); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + fs.writeFileSync( + path.join(dir, 'ai-default.ts'), + 'export const defaulted = true;\n', + ); + await gitInRepo(dir, ['add', 'ai-default.ts']); + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/default-ai","message":"feat: default ai mode"}', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await create( + undefined as unknown as string, + dir, + {}, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result.branch).toBe('feat/default-ai'); + expect(result.committed).toBe('feat: default ai mode'); + }); + + it('allows --no-ai to override an enabled repo default', async () => { + await writeConfig( + { + aiAssistantEnabled: true, + ai: { + defaults: { + createMetadata: true, + submitDescription: false, + flow: false, + }, + }, + }, + dir, + ); + + const generateText = vi.fn(); + const createGoogleGenerativeAI = vi.fn(); + const createGateway = vi.fn(); + + await expect( + create( + undefined as unknown as string, + dir, + { noAi: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ), + ).rejects.toThrow( + "Branch name is required. Pass '' or use '--ai'.", + ); + + expect(generateText).not.toHaveBeenCalled(); + }); + + it('allows --ai to override a disabled repo default', async () => { + await writeConfig( + { + aiAssistantEnabled: true, + ai: { + defaults: { + createMetadata: false, + submitDescription: false, + flow: false, + }, + }, + }, + dir, + ); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + fs.writeFileSync( + path.join(dir, 'ai-forced.ts'), + 'export const forced = true;\n', + ); + await gitInRepo(dir, ['add', 'ai-forced.ts']); + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/forced-ai","message":"feat: forced ai mode"}', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await create( + undefined as unknown as string, + dir, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result.branch).toBe('feat/forced-ai'); + expect(result.committed).toBe('feat: forced ai mode'); + }); + + it('rejects combining --ai and --no-ai', async () => { + await expect( + create(undefined as unknown as string, dir, { + ai: true, + noAi: true, + }), + ).rejects.toThrow("'--ai' cannot be combined with '--no-ai'."); + }); + it('creates branch and commit from AI output using staged changes', async () => { await writeConfig({ aiAssistantEnabled: true }, dir); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; @@ -228,6 +360,48 @@ describe('create with --ai', () => { ); }); + it('preserves multiline AI commit messages when applying them', async () => { + await writeConfig({ aiAssistantEnabled: true }, dir); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + fs.writeFileSync( + path.join(dir, '.gitmessage'), + 'feat(scope): summary\n\n## Testing\n- [ ] added\n', + ); + await gitInRepo(dir, ['config', 'commit.template', '.gitmessage']); + fs.writeFileSync( + path.join(dir, 'ai-template.ts'), + 'export const aiTemplate = 1;\n', + ); + await gitInRepo(dir, ['add', 'ai-template.ts']); + + const generateText = vi.fn().mockResolvedValue({ + text: JSON.stringify({ + branch: 'feat/template-aware', + message: 'feat: preserve template\n\n## Testing\n- [x] added coverage', + }), + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await create( + undefined as unknown as string, + dir, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result.committed).toContain('## Testing'); + const { stdout } = await gitInRepo(dir, ['log', '-1', '--format=%B']); + expect(stdout).toContain('feat: preserve template'); + expect(stdout).toContain('## Testing'); + expect(stdout).toContain('- [x] added coverage'); + }); + it('uses DUBSTACK_GEMINI_MODEL override when provided', async () => { await writeConfig({ aiAssistantEnabled: true }, dir); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 2a7fa9f..10574b9 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,27 +1,36 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import type { LanguageModel } from 'ai'; import { createGateway, generateText } from 'ai'; +import { buildAiDiffContext } from '../lib/ai-diff-context'; +import { + type AiMetadataDependencies, + generateCreateMetadata, +} from '../lib/ai-metadata'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; import { branchExists, commitStaged, + commitStagedFromFile, createBranch, getBranchTip, getCurrentBranch, getDiff, + getDiffFileNames, + getDiffNumStat, hasStagedChanges, interactiveStage, isValidBranchName, stageAll, stageUpdate, } from '../lib/git'; -import { redactSensitiveText } from '../lib/history'; +import { readMetadataTemplates } from '../lib/metadata-templates'; import { addBranchToStack, ensureState, writeState } from '../lib/state'; +import { withTempMarkdownFile } from '../lib/temp-text-file'; import { saveUndoEntry } from '../lib/undo-log'; interface CreateOptions { ai?: boolean; + noAi?: boolean; message?: string; all?: boolean; update?: boolean; @@ -34,11 +43,7 @@ interface CreateResult { committed?: string; } -interface CreateDependencies { - generateText: typeof generateText; - createGoogleGenerativeAI: typeof createGoogleGenerativeAI; - createGateway: typeof createGateway; -} +type CreateDependencies = AiMetadataDependencies; const DEFAULT_DEPS: CreateDependencies = { generateText, @@ -46,9 +51,6 @@ const DEFAULT_DEPS: CreateDependencies = { createGateway, }; -const CONVENTIONAL_COMMIT_RE = - /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/; - /** * Creates a new branch stacked on top of the current branch. * @@ -69,7 +71,18 @@ export async function create( deps: CreateDependencies = DEFAULT_DEPS, ): Promise { const normalizedOptions = options ?? {}; - const useAi = normalizedOptions.ai ?? false; + + if (normalizedOptions.ai && normalizedOptions.noAi) { + throw new DubError("'--ai' cannot be combined with '--no-ai'."); + } + + const config = await readConfig(cwd); + const useAi = + normalizedOptions.ai === true + ? true + : normalizedOptions.noAi === true + ? false + : config.ai.defaults.createMetadata; if ( (normalizedOptions.all || @@ -127,7 +140,6 @@ export async function create( } if (useAi) { - const config = await readConfig(cwd); if (!config.aiAssistantEnabled) { throw new DubError( "AI assistant is disabled for this repo. Enable it with 'dub config ai-assistant on'.", @@ -135,7 +147,22 @@ export async function create( } const stagedDiff = await getDiff(cwd, true); - const generated = await generateBranchAndCommitFromAi(stagedDiff, deps); + const [stagedFiles, stagedDiffStats] = await Promise.all([ + getDiffFileNames(cwd, true), + getDiffNumStat(cwd, true), + ]); + const templates = await readMetadataTemplates(cwd); + const generated = await generateCreateMetadata( + buildAiDiffContext({ + rawDiff: stagedDiff, + filePaths: stagedFiles, + diffStats: stagedDiffStats, + }), + deps, + { + commitTemplate: templates.commitTemplate, + }, + ); branchName = generated.branch; commitMessage = generated.message; } @@ -173,7 +200,17 @@ export async function create( if (commitMessage) { try { - await commitStaged(commitMessage, cwd); + if (commitMessage.includes('\n')) { + await withTempMarkdownFile( + 'commit-message', + commitMessage, + async (filePath) => { + await commitStagedFromFile(filePath, cwd); + }, + ); + } else { + await commitStaged(commitMessage, cwd); + } } catch (error) { const reason = error instanceof DubError ? error.message : String(error); throw new DubError( @@ -185,157 +222,3 @@ export async function create( return { branch: branchName, parent }; } - -async function generateBranchAndCommitFromAi( - stagedDiff: string, - deps: CreateDependencies, -): Promise<{ branch: string; message: string }> { - const resolved = resolveModel(deps); - const redactedDiff = redactSensitiveText(stagedDiff).trim(); - const diffForPrompt = truncate(redactedDiff, 12_000); - const prompt = [ - 'Generate a git branch name and conventional commit message from the staged diff.', - 'Return JSON only, exactly like: {"branch":"feat/your-branch","message":"feat: summary"}', - 'Rules:', - '- branch must be lowercase, slash-delimited, and kebab-case.', - '- message must be a Conventional Commit subject line.', - '- keep message under 72 characters when possible.', - '- do not include markdown fences.', - '', - 'STAGED_DIFF_START', - diffForPrompt.length > 0 ? diffForPrompt : '[No textual diff available]', - 'STAGED_DIFF_END', - ].join('\n'); - - const result = await deps.generateText({ - model: resolved.model, - system: - 'You produce concise git metadata. Output strict JSON only and never add extra commentary.', - prompt, - }); - - return parseAiCreateResponse(result.text); -} - -function resolveModel(deps: CreateDependencies): { - provider: 'google' | 'gateway'; - model: LanguageModel; - modelId: string; -} { - const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); - if (geminiApiKey) { - const geminiModel = - process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; - const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); - return { - provider: 'google', - model: google(geminiModel), - modelId: geminiModel, - }; - } - - const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); - if (gatewayApiKey) { - const gatewayModel = - process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; - const gateway = deps.createGateway({ apiKey: gatewayApiKey }); - return { - provider: 'gateway', - model: gateway(gatewayModel), - modelId: gatewayModel, - }; - } - - throw new DubError( - "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", - ); -} - -function parseAiCreateResponse(text: string): { - branch: string; - message: string; -} { - const candidate = extractJsonObject(text); - let parsed: unknown; - try { - parsed = JSON.parse(candidate); - } catch { - throw new DubError( - "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", - ); - } - - if (!parsed || typeof parsed !== 'object') { - throw new DubError( - "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", - ); - } - - const rawBranch = getStringValue(parsed, 'branch'); - const rawMessage = getStringValue(parsed, 'message'); - const branch = normalizeBranchName(rawBranch); - const message = normalizeCommitMessage(rawMessage); - - if (branch.length === 0) { - throw new DubError('AI assistant generated an empty branch name.'); - } - - if (!CONVENTIONAL_COMMIT_RE.test(message)) { - throw new DubError( - "AI assistant generated a non-conventional commit message. Re-run '--ai' or pass '-m' manually.", - ); - } - - return { branch, message }; -} - -function getStringValue(source: object, key: string): string { - const value = (source as Record)[key]; - if (typeof value !== 'string') { - throw new DubError(`AI assistant metadata is missing '${key}'.`); - } - return value; -} - -function normalizeBranchName(value: string): string { - return value - .trim() - .replace(/^`+|`+$/g, '') - .replace(/^refs\/heads\//, '') - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9./_-]+/g, '-') - .replace(/\/+/g, '/') - .replace(/-+/g, '-') - .replace(/^\/+|\/+$/g, '') - .replace(/^\.+/, '') - .replace(/\.+$/, ''); -} - -function normalizeCommitMessage(value: string): string { - return value - .trim() - .replace(/^`+|`+$/g, '') - .replace(/\s+/g, ' '); -} - -function extractJsonObject(text: string): string { - const trimmed = text.trim(); - const withoutFences = - trimmed.startsWith('```') && trimmed.endsWith('```') - ? trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '') - : trimmed; - const start = withoutFences.indexOf('{'); - const end = withoutFences.lastIndexOf('}'); - if (start === -1 || end === -1 || end <= start) { - throw new DubError( - "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", - ); - } - return withoutFences.slice(start, end + 1); -} - -function truncate(value: string, max: number): string { - if (value.length <= max) return value; - return `${value.slice(0, max)}\n...[truncated]`; -} diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts new file mode 100644 index 0000000..5aadc84 --- /dev/null +++ b/packages/cli/src/commands/flow.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; +import { flow } from './flow'; + +function createRenderer() { + return { + renderMarkdown: vi.fn(), + renderPreview: vi.fn(), + renderStatus: vi.fn(), + renderToolActivity: vi.fn(), + }; +} + +describe('flow', () => { + it('stages all changes, previews generated content, and delegates create plus submit when auto-approved', async () => { + const renderer = createRenderer(); + const create = vi.fn().mockResolvedValue({ + branch: 'feat/flow-preview', + parent: 'main', + }); + const submit = vi.fn().mockResolvedValue({ + pushed: ['feat/flow-preview'], + created: ['feat/flow-preview'], + updated: [], + path: 'current', + dryRun: false, + fallbackApplied: false, + }); + const commitStagedFromFile = vi.fn().mockResolvedValue(undefined); + const getDiffFileNames = vi + .fn() + .mockResolvedValue(['packages/cli/src/commands/flow.ts']); + const getDiffNumStat = vi.fn().mockResolvedValue([ + { + path: 'packages/cli/src/commands/flow.ts', + additions: 10, + deletions: 2, + }, + ]); + + const result = await flow( + '/repo', + { + all: true, + yes: true, + }, + { + readConfig: vi.fn().mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + flow: true, + }, + }, + }), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + hasStagedChanges: vi.fn().mockResolvedValue(true), + stageAll: vi.fn().mockResolvedValue(undefined), + stageUpdate: vi.fn(), + interactiveStage: vi.fn(), + getDiff: vi.fn().mockResolvedValue('diff --git a/file b/file'), + getDiffFileNames, + getDiffNumStat, + generateFlowMetadata: vi.fn().mockResolvedValue({ + branch: 'feat/flow-preview', + commitMessage: 'feat: generated from flow', + prDescription: '## Summary\n\nGenerated PR description', + }), + create, + submit, + commitStagedFromFile, + createTerminalRenderer: vi.fn().mockReturnValue(renderer), + promptApproval: vi.fn(), + editGeneratedContent: vi.fn(), + }, + ); + + expect(result.branch).toBe('feat/flow-preview'); + expect(result.dryRun).toBe(false); + expect(renderer.renderPreview).toHaveBeenCalled(); + expect(getDiffFileNames).toHaveBeenCalledWith('/repo', true); + expect(getDiffNumStat).toHaveBeenCalledWith('/repo', true); + expect(create).toHaveBeenCalledWith('feat/flow-preview', '/repo', {}); + expect(commitStagedFromFile).toHaveBeenCalledWith( + expect.any(String), + '/repo', + ); + expect(submit).toHaveBeenCalledWith('/repo', false, { + path: 'current', + fix: false, + summaryOverrides: new Map([ + ['feat/flow-preview', '## Summary\n\nGenerated PR description'], + ]), + }); + }); + + it('supports interactive edit before applying generated content', async () => { + const renderer = createRenderer(); + const commitStagedFromFile = vi + .fn() + .mockImplementation(async (filePath: string) => { + const { readFile } = await import('node:fs/promises'); + const body = await readFile(filePath, 'utf8'); + expect(body).toContain('feat: edited commit'); + }); + + await flow( + '/repo', + {}, + { + readConfig: vi.fn().mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + flow: true, + }, + }, + }), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + hasStagedChanges: vi.fn().mockResolvedValue(true), + stageAll: vi.fn(), + stageUpdate: vi.fn(), + interactiveStage: vi.fn(), + getDiff: vi.fn().mockResolvedValue('diff --git a/file b/file'), + getDiffFileNames: vi + .fn() + .mockResolvedValue(['packages/cli/src/commands/flow.ts']), + getDiffNumStat: vi.fn().mockResolvedValue([ + { + path: 'packages/cli/src/commands/flow.ts', + additions: 10, + deletions: 2, + }, + ]), + generateFlowMetadata: vi.fn().mockResolvedValue({ + branch: 'feat/flow-preview', + commitMessage: 'feat: generated from flow', + prDescription: 'Generated PR description', + }), + create: vi.fn().mockResolvedValue({ + branch: 'feat/flow-preview', + parent: 'main', + }), + submit: vi.fn().mockResolvedValue({ + pushed: ['feat/flow-preview'], + created: ['feat/flow-preview'], + updated: [], + path: 'current', + dryRun: false, + fallbackApplied: false, + }), + commitStagedFromFile, + createTerminalRenderer: vi.fn().mockReturnValue(renderer), + promptApproval: vi.fn().mockResolvedValue('edit'), + editGeneratedContent: vi.fn().mockResolvedValue({ + commitMessage: 'feat: edited commit', + prDescription: '## Edited\n\nUpdated PR body', + }), + }, + ); + + expect(commitStagedFromFile).toHaveBeenCalled(); + }); + + it('aborts without mutating when approval is declined', async () => { + const create = vi.fn(); + const submit = vi.fn(); + + const result = await flow( + '/repo', + {}, + { + readConfig: vi.fn().mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + flow: true, + }, + }, + }), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + hasStagedChanges: vi.fn().mockResolvedValue(true), + stageAll: vi.fn(), + stageUpdate: vi.fn(), + interactiveStage: vi.fn(), + getDiff: vi.fn().mockResolvedValue('diff --git a/file b/file'), + getDiffFileNames: vi + .fn() + .mockResolvedValue(['packages/cli/src/commands/flow.ts']), + getDiffNumStat: vi.fn().mockResolvedValue([ + { + path: 'packages/cli/src/commands/flow.ts', + additions: 10, + deletions: 2, + }, + ]), + generateFlowMetadata: vi.fn().mockResolvedValue({ + branch: 'feat/flow-preview', + commitMessage: 'feat: generated from flow', + prDescription: 'Generated PR description', + }), + create, + submit, + commitStagedFromFile: vi.fn(), + createTerminalRenderer: vi.fn().mockReturnValue(createRenderer()), + promptApproval: vi.fn().mockResolvedValue('cancel'), + editGeneratedContent: vi.fn(), + }, + ); + + expect(result.aborted).toBe(true); + expect(create).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts new file mode 100644 index 0000000..770962f --- /dev/null +++ b/packages/cli/src/commands/flow.ts @@ -0,0 +1,353 @@ +import * as fs from 'node:fs'; +import { stdin as input, stdout as output } from 'node:process'; +import * as readline from 'node:readline/promises'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createGateway, generateText } from 'ai'; +import { execa } from 'execa'; +import { buildAiDiffContext } from '../lib/ai-diff-context'; +import { + type AiMetadataDependencies, + generateFlowMetadata, +} from '../lib/ai-metadata'; +import { readConfig } from '../lib/config'; +import { DubError } from '../lib/errors'; +import { + commitStagedFromFile, + getCurrentBranch, + getDiff, + getDiffFileNames, + getDiffNumStat, + hasStagedChanges, + interactiveStage, + stageAll, + stageUpdate, +} from '../lib/git'; +import { readMetadataTemplates } from '../lib/metadata-templates'; +import { + removeTempFile, + withTempMarkdownFile, + writeTempMarkdownFile, +} from '../lib/temp-text-file'; +import { createTerminalRenderer } from '../lib/terminal-render'; +import { create } from './create'; +import { type SubmitResult, submit } from './submit'; + +type ApprovalChoice = 'approve' | 'edit' | 'cancel'; + +export interface FlowOptions { + ai?: boolean; + noAi?: boolean; + yes?: boolean; + all?: boolean; + update?: boolean; + patch?: boolean; + dryRun?: boolean; +} + +export interface FlowResult { + branch: string; + commitMessage: string; + prDescription: string; + dryRun: boolean; + aborted: boolean; + submitted?: SubmitResult; +} + +interface TerminalRendererLike { + renderMarkdown: (markdown: string) => void; + renderPreview: (title: string, markdown: string) => void; + renderStatus: (status: string) => void; + renderToolActivity: (toolName: string, detail?: string) => void; +} + +interface FlowDependencies extends AiMetadataDependencies { + generateFlowMetadata: typeof generateFlowMetadata; + readMetadataTemplates: typeof readMetadataTemplates; + readConfig: typeof readConfig; + getCurrentBranch: typeof getCurrentBranch; + hasStagedChanges: typeof hasStagedChanges; + stageAll: typeof stageAll; + stageUpdate: typeof stageUpdate; + interactiveStage: typeof interactiveStage; + getDiff: typeof getDiff; + getDiffFileNames: typeof getDiffFileNames; + getDiffNumStat: typeof getDiffNumStat; + create: typeof create; + submit: typeof submit; + commitStagedFromFile: typeof commitStagedFromFile; + createTerminalRenderer: typeof createTerminalRenderer; + promptApproval: () => Promise; + editGeneratedContent: ( + cwd: string, + content: { commitMessage: string; prDescription: string }, + ) => Promise<{ commitMessage: string; prDescription: string }>; +} + +const DEFAULT_DEPS: FlowDependencies = { + generateText, + createGoogleGenerativeAI, + createGateway, + generateFlowMetadata, + readMetadataTemplates, + readConfig, + getCurrentBranch, + hasStagedChanges, + stageAll, + stageUpdate, + interactiveStage, + getDiff, + getDiffFileNames, + getDiffNumStat, + create, + submit, + commitStagedFromFile, + createTerminalRenderer, + promptApproval: promptApprovalChoice, + editGeneratedContent: editGeneratedContent, +}; + +export async function flow( + cwd: string, + options: FlowOptions = {}, + deps: Partial = {}, +): Promise { + const resolvedDeps: FlowDependencies = { + ...DEFAULT_DEPS, + ...deps, + }; + + if (options.ai && options.noAi) { + throw new DubError("'--ai' cannot be combined with '--no-ai'."); + } + + validateStageMode(options); + + const config = await resolvedDeps.readConfig(cwd); + const useAi = + options.ai === true + ? true + : options.noAi === true + ? false + : config.ai.defaults.flow; + + if (!useAi) { + throw new DubError( + "dub flow requires AI. Re-run with '--ai' or enable it with 'dub config ai-defaults flow on'.", + ); + } + + if (!config.aiAssistantEnabled) { + throw new DubError( + "AI assistant is disabled for this repo. Enable it with 'dub config ai-assistant on'.", + ); + } + + await stageChanges(cwd, options, resolvedDeps); + + if (!(await resolvedDeps.hasStagedChanges(cwd))) { + throw new DubError( + "No staged changes. Stage files with 'git add' or rerun with '-a', '-u', or '-p'.", + ); + } + + const parentBranch = await resolvedDeps.getCurrentBranch(cwd); + const stagedDiff = await resolvedDeps.getDiff(cwd, true); + const [stagedFiles, stagedDiffStats] = await Promise.all([ + resolvedDeps.getDiffFileNames(cwd, true), + resolvedDeps.getDiffNumStat(cwd, true), + ]); + const templates = await resolvedDeps.readMetadataTemplates(cwd); + const generated = await resolvedDeps.generateFlowMetadata( + { + parentBranch, + staged: buildAiDiffContext({ + rawDiff: stagedDiff, + filePaths: stagedFiles, + diffStats: stagedDiffStats, + }), + }, + resolvedDeps, + { + commitTemplate: templates.commitTemplate, + prTemplate: templates.prTemplate, + }, + ); + let commitMessage = generated.commitMessage; + let prDescription = generated.prDescription; + + const renderer = resolvedDeps.createTerminalRenderer( + output, + ) as TerminalRendererLike; + renderFlowPreview(renderer, { + branch: generated.branch, + commitMessage, + prDescription, + }); + + if (options.dryRun) { + return { + branch: generated.branch, + commitMessage, + prDescription, + dryRun: true, + aborted: false, + }; + } + + if (!options.yes) { + const approval = await resolvedDeps.promptApproval(); + if (approval === 'cancel') { + return { + branch: generated.branch, + commitMessage, + prDescription, + dryRun: false, + aborted: true, + }; + } + if (approval === 'edit') { + const edited = await resolvedDeps.editGeneratedContent(cwd, { + commitMessage, + prDescription, + }); + commitMessage = edited.commitMessage.trim(); + prDescription = edited.prDescription.trim(); + renderFlowPreview(renderer, { + branch: generated.branch, + commitMessage, + prDescription, + }); + } + } + + await resolvedDeps.create(generated.branch, cwd, {}); + await withTempMarkdownFile( + 'commit-message', + commitMessage, + async (filePath) => resolvedDeps.commitStagedFromFile(filePath, cwd), + ); + + const submitted = await resolvedDeps.submit(cwd, false, { + path: 'current', + fix: false, + summaryOverrides: new Map([[generated.branch, prDescription]]), + }); + + return { + branch: generated.branch, + commitMessage, + prDescription, + dryRun: false, + aborted: false, + submitted, + }; +} + +function validateStageMode(options: FlowOptions): void { + const activeModes = [options.all, options.update, options.patch].filter( + Boolean, + ); + if (activeModes.length > 1) { + throw new DubError( + "Choose only one staging mode: '--all', '--update', or '--patch'.", + ); + } +} + +async function stageChanges( + cwd: string, + options: FlowOptions, + deps: FlowDependencies, +): Promise { + if (options.patch) { + await deps.interactiveStage(cwd); + return; + } + if (options.all) { + await deps.stageAll(cwd); + return; + } + if (options.update) { + await deps.stageUpdate(cwd); + } +} + +function renderFlowPreview( + renderer: TerminalRendererLike, + content: { + branch: string; + commitMessage: string; + prDescription: string; + }, +): void { + renderer.renderPreview('Branch Name', content.branch); + renderer.renderPreview( + 'Commit Message', + ['```text', content.commitMessage, '```'].join('\n'), + ); + renderer.renderPreview('PR Description', content.prDescription); + renderer.renderPreview( + 'Planned Commands', + [ + '```bash', + `dub create ${content.branch}`, + 'git commit --file ', + 'dub submit', + '```', + ].join('\n'), + ); +} + +async function promptApprovalChoice(): Promise { + if (!(process.stdout.isTTY && process.stdin.isTTY)) { + throw new DubError( + "Flow requires confirmation in an interactive terminal. Re-run with '-y' to auto-approve.", + ); + } + + const rl = readline.createInterface({ input, output }); + try { + const answer = await rl.question('[Y]es [E]dit [C]ancel: '); + const normalized = answer.trim().toLowerCase(); + if (normalized === 'e' || normalized === 'edit') return 'edit'; + if (normalized === 'c' || normalized === 'cancel' || normalized === 'n') { + return 'cancel'; + } + return 'approve'; + } finally { + rl.close(); + } +} + +async function editGeneratedContent( + cwd: string, + content: { commitMessage: string; prDescription: string }, +): Promise<{ commitMessage: string; prDescription: string }> { + const commitFile = writeTempMarkdownFile( + 'commit-message', + content.commitMessage, + ); + const prFile = writeTempMarkdownFile('pr-description', content.prDescription); + + try { + await openEditor(commitFile, cwd); + await openEditor(prFile, cwd); + + return { + commitMessage: fs.readFileSync(commitFile, 'utf8').trim(), + prDescription: fs.readFileSync(prFile, 'utf8').trim(), + }; + } finally { + removeTempFile(commitFile); + removeTempFile(prFile); + } +} + +async function openEditor(filePath: string, cwd: string): Promise { + const editor = + process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || 'vi'; + await execa(editor, [filePath], { + cwd, + stdio: 'inherit', + }); +} diff --git a/packages/cli/src/commands/submit.test.ts b/packages/cli/src/commands/submit.test.ts index bacf285..31aba52 100644 --- a/packages/cli/src/commands/submit.test.ts +++ b/packages/cli/src/commands/submit.test.ts @@ -1,8 +1,11 @@ +import { readFile } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../lib/git.js', () => ({ getBranchTip: vi.fn(), getCurrentBranch: vi.fn(), + getDiff: vi.fn(), + getDiffBetween: vi.fn(), getLastCommitMessage: vi.fn(), pushBranch: vi.fn(), })); @@ -24,9 +27,20 @@ vi.mock('../lib/state.js', async (importOriginal) => { }; }); +vi.mock('../lib/config.js', () => ({ + readConfig: vi.fn(), +})); + +vi.mock('../lib/metadata-templates.js', () => ({ + readMetadataTemplates: vi.fn(), +})); + +import { readConfig } from '../lib/config'; import { getBranchTip, getCurrentBranch, + getDiff, + getDiffBetween, getLastCommitMessage, pushBranch, } from '../lib/git'; @@ -37,12 +51,15 @@ import { getPr, updatePrBody, } from '../lib/github'; +import { readMetadataTemplates } from '../lib/metadata-templates'; import type { DubState } from '../lib/state'; import { readState, writeState } from '../lib/state'; import { submit } from './submit'; const mockGetCurrentBranch = getCurrentBranch as ReturnType; const mockGetBranchTip = getBranchTip as ReturnType; +const mockGetDiff = getDiff as ReturnType; +const mockGetDiffBetween = getDiffBetween as ReturnType; const mockGetLastCommitMessage = getLastCommitMessage as ReturnType< typeof vi.fn >; @@ -54,6 +71,10 @@ const mockCreatePr = createPr as ReturnType; const mockUpdatePrBody = updatePrBody as ReturnType; const mockReadState = readState as ReturnType; const mockWriteState = writeState as ReturnType; +const mockReadConfig = readConfig as ReturnType; +const mockReadMetadataTemplates = readMetadataTemplates as ReturnType< + typeof vi.fn +>; function makeState( branches: { @@ -81,15 +102,330 @@ beforeEach(() => { vi.clearAllMocks(); mockEnsureGhInstalled.mockResolvedValue(undefined); mockCheckGhAuth.mockResolvedValue(undefined); + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: false, + ai: { + defaults: { + submitDescription: false, + }, + }, + }); mockWriteState.mockResolvedValue(undefined); mockPushBranch.mockResolvedValue(undefined); mockGetBranchTip.mockImplementation( async (branch: string) => `${branch}-sha`, ); + mockGetDiff.mockResolvedValue('diff --git a/file.ts b/file.ts'); + mockGetDiffBetween.mockResolvedValue('diff --git a/file.ts b/file.ts'); + mockGetLastCommitMessage.mockResolvedValue('feat: existing title'); mockUpdatePrBody.mockResolvedValue(undefined); + mockReadMetadataTemplates.mockResolvedValue({ + prTemplate: null, + commitTemplate: null, + }); }); describe('submit', () => { + it('uses the repo default to enable AI PR descriptions when no flag is passed', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: true, + }, + }, + }); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: existing title', + body: 'User intro', + }); + const generateText = vi.fn().mockResolvedValue({ + text: '## Summary\n\nGenerated PR description', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + let updatedBody = ''; + mockUpdatePrBody.mockImplementationOnce(async (_number, bodyFile) => { + updatedBody = await readFile(bodyFile, 'utf8'); + }); + + await submit( + '/repo', + false, + {}, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'google-model', + }), + ); + expect(updatedBody).toContain('Generated PR description'); + expect(updatedBody).toContain('User intro'); + }); + + it('allows --no-ai to override an enabled repo default', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: true, + }, + }, + }); + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: existing title', + body: 'User intro', + }); + const generateText = vi.fn(); + const createGoogleGenerativeAI = vi.fn(); + const createGateway = vi.fn(); + + await submit( + '/repo', + false, + { noAi: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(generateText).not.toHaveBeenCalled(); + }); + + it('preserves user-authored body content and replaces only the ai-managed summary', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: false, + }, + }, + }); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: existing title', + body: [ + 'User intro', + '', + '', + 'Old summary', + '', + '', + 'Extra note', + ].join('\n'), + }); + const generateText = vi.fn().mockResolvedValue({ + text: 'New generated summary', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + let updatedBody = ''; + mockUpdatePrBody.mockImplementationOnce(async (_number, bodyFile) => { + updatedBody = await readFile(bodyFile, 'utf8'); + }); + + await submit( + '/repo', + false, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(updatedBody).toContain('User intro'); + expect(updatedBody).toContain('Extra note'); + expect(updatedBody).toContain('New generated summary'); + expect(updatedBody).not.toContain('Old summary'); + }); + + it('uses the branch diff against the parent even for the current branch', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: false, + }, + }, + }); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: existing title', + body: 'User intro', + }); + const generateText = vi.fn().mockResolvedValue({ + text: 'Generated PR summary', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + await submit( + '/repo', + false, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(mockGetDiffBetween).toHaveBeenCalledWith('main', 'feat/a', '/repo'); + expect(mockGetDiff).not.toHaveBeenCalled(); + }); + + it('surfaces diff lookup failures when ai descriptions are requested', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: false, + }, + }, + }); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: existing title', + body: 'User intro', + }); + mockGetDiffBetween.mockRejectedValueOnce(new Error('bad refs')); + + await expect( + submit( + '/repo', + false, + { ai: true }, + { + generateText: vi.fn(), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + ), + ).rejects.toThrow( + "Failed to generate an AI PR summary for 'feat/a' because its diff could not be loaded.", + ); + }); + + it('creates new PRs with the last commit message as the title even when AI descriptions are enabled', async () => { + mockReadConfig.mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + submitDescription: false, + }, + }, + }); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + mockGetCurrentBranch.mockResolvedValue('feat/a'); + mockReadState.mockResolvedValue( + makeState([ + { name: 'main', parent: null, type: 'root' }, + { name: 'feat/a', parent: 'main' }, + ]), + ); + mockGetPr.mockResolvedValue(null); + mockGetLastCommitMessage.mockResolvedValue('feat: exact squash title'); + mockCreatePr.mockResolvedValue({ + number: 42, + url: 'https://github.com/o/r/pull/42', + title: 'feat: exact squash title', + body: '', + }); + const generateText = vi.fn().mockResolvedValue({ + text: 'Generated PR summary', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + await submit( + '/repo', + false, + { ai: true }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(mockCreatePr).toHaveBeenCalledWith( + 'feat/a', + 'main', + 'feat: exact squash title', + expect.any(String), + '/repo', + ); + }); + + it('rejects combining --ai and --no-ai', async () => { + await expect( + submit('/repo', false, { + ai: true, + noAi: true, + }), + ).rejects.toThrow("'--ai' cannot be combined with '--no-ai'."); + }); + it('throws when branch is not in any stack', async () => { mockGetCurrentBranch.mockResolvedValue('orphan'); mockReadState.mockResolvedValue({ stacks: [] }); diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index be791bc..5b6c940 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -1,10 +1,15 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createGateway, generateText } from 'ai'; +import { + type AiMetadataDependencies, + generatePrDescriptionSummary, +} from '../lib/ai-metadata'; +import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; import { getBranchTip, getCurrentBranch, + getDiffBetween, getLastCommitMessage, pushBranch, } from '../lib/git'; @@ -16,6 +21,7 @@ import { type PrInfo, updatePrBody, } from '../lib/github'; +import { readMetadataTemplates } from '../lib/metadata-templates'; import { buildMetadataBlock, buildStackTable, @@ -30,6 +36,7 @@ import { topologicalOrder, writeState, } from '../lib/state'; +import { withTempMarkdownFile } from '../lib/temp-text-file'; export type SubmitPathMode = 'current' | 'stack'; @@ -39,8 +46,11 @@ interface SubmitBranchingBlocker { } export interface SubmitOptions { + ai?: boolean; + noAi?: boolean; path?: SubmitPathMode; fix?: boolean; + summaryOverrides?: Map; } export interface SubmitPlan { @@ -62,6 +72,14 @@ export interface SubmitResult { fallbackApplied: boolean; } +type SubmitDependencies = AiMetadataDependencies; + +const DEFAULT_DEPS: SubmitDependencies = { + generateText, + createGoogleGenerativeAI, + createGateway, +}; + /** * Pushes branches in the current stack and creates/updates GitHub PRs. * @@ -73,8 +91,27 @@ export async function submit( cwd: string, dryRun: boolean, options: SubmitOptions = {}, + deps: SubmitDependencies = DEFAULT_DEPS, ): Promise { + if (options.ai && options.noAi) { + throw new DubError("'--ai' cannot be combined with '--no-ai'."); + } + const plan = await getSubmitPlan(cwd, options); + const config = await readConfig(cwd); + const useAi = + options.ai === true + ? true + : options.noAi === true + ? false + : config.ai.defaults.submitDescription; + + if (useAi && !config.aiAssistantEnabled) { + throw new DubError( + "AI assistant is disabled for this repo. Enable it with 'dub config ai-assistant on'.", + ); + } + const templates = useAi ? await readMetadataTemplates(cwd) : null; await ensureGhInstalled(); await checkGhAuth(); @@ -124,19 +161,25 @@ export async function submit( result.updated.push(branch.name); } else { const title = await getLastCommitMessage(branch.name, cwd); - const tmpFile = writeTempBody(''); - try { - const created = await createPr(branch.name, base, title, tmpFile, cwd); - prMap.set(branch.name, created); - result.created.push(branch.name); - } finally { - cleanupTempFile(tmpFile); - } + const created = await withTempMarkdownFile( + 'pr-body', + '', + async (tmpFile) => { + return createPr(branch.name, base, title, tmpFile, cwd); + }, + ); + prMap.set(branch.name, created); + result.created.push(branch.name); } } if (!dryRun) { - await updateAllPrBodies(plan.branches, prMap, plan.stack.id, cwd); + await updateAllPrBodies(plan.branches, prMap, plan.stack.id, cwd, { + useAi, + deps, + summaryOverrides: options.summaryOverrides, + prTemplate: templates?.prTemplate ?? null, + }); for (const branch of plan.branches) { const pr = prMap.get(branch.name); @@ -313,6 +356,12 @@ async function updateAllPrBodies( prMap: Map, stackId: string, cwd: string, + options: { + useAi: boolean; + deps: SubmitDependencies; + summaryOverrides?: Map; + prTemplate: string | null; + }, ): Promise { const tableEntries = new Map(); for (const branch of branches) { @@ -326,6 +375,7 @@ async function updateAllPrBodies( const branch = branches[i]; const pr = prMap.get(branch.name); if (!pr) continue; + const commitMessage = await getLastCommitMessage(branch.name, cwd); const prevPr = i > 0 ? (prMap.get(branches[i - 1].name)?.number ?? null) : null; @@ -344,28 +394,51 @@ async function updateAllPrBodies( ); const existingBody = pr.body; - const finalBody = composePrBody(existingBody, stackTable, metadataBlock); + const aiSummaryOverride = options.summaryOverrides?.get(branch.name); + const aiSummary = + typeof aiSummaryOverride === 'string' + ? aiSummaryOverride + : options.useAi + ? await generatePrDescriptionSummary( + { + branch: branch.name, + baseBranch: branch.parent as string, + commitMessage, + diff: await getDiffForPrDescription( + branch.name, + branch.parent as string, + cwd, + ), + }, + options.deps, + { + prTemplate: options.prTemplate, + }, + ) + : ''; + const finalBody = composePrBody( + existingBody, + aiSummary, + stackTable, + metadataBlock, + ); - const tmpFile = writeTempBody(finalBody); - try { + await withTempMarkdownFile('pr-body', finalBody, async (tmpFile) => { await updatePrBody(pr.number, tmpFile, cwd); - } finally { - cleanupTempFile(tmpFile); - } + }); } } -function writeTempBody(content: string): string { - const tmpDir = os.tmpdir(); - const tmpFile = path.join(tmpDir, `dubstack-body-${Date.now()}.md`); - fs.writeFileSync(tmpFile, content); - return tmpFile; -} - -function cleanupTempFile(filePath: string): void { +async function getDiffForPrDescription( + branchName: string, + baseBranch: string, + cwd: string, +): Promise { try { - fs.unlinkSync(filePath); + return await getDiffBetween(baseBranch, branchName, cwd); } catch { - // Best-effort cleanup + throw new DubError( + `Failed to generate an AI PR summary for '${branchName}' because its diff could not be loaded.`, + ); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8bdd492..0dda8bc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -33,6 +33,7 @@ import { continueCommand } from './commands/continue'; import { create } from './commands/create'; import { deleteCommand } from './commands/delete'; import { doctor } from './commands/doctor'; +import { flow } from './commands/flow'; import { init } from './commands/init'; import { log } from './commands/log'; import { mergeCheck } from './commands/merge-check'; @@ -124,6 +125,7 @@ program '-i, --ai', 'AI-generate branch + conventional commit from staged changes', ) + .option('--no-ai', 'Disable AI generation for this invocation') .addHelpText( 'after', ` @@ -131,7 +133,8 @@ Examples: $ dub create feat/api Create branch only $ dub create feat/api -m "feat: add API" Create branch + commit staged $ dub create feat/api -am "feat: add API" Stage all + create + commit - $ dub create --ai AI-generate branch + commit from staged`, + $ dub create --ai AI-generate branch + commit from staged + $ dub create --no-ai feat/api Override repo AI defaults for one create`, ) .action( async ( @@ -142,6 +145,7 @@ Examples: update?: boolean; patch?: boolean; ai?: boolean; + noAi?: boolean; }, ) => { const result = await create(branchName, process.cwd(), { @@ -150,6 +154,7 @@ Examples: update: options.update, patch: options.patch, ai: options.ai, + noAi: options.noAi, }); if (result.committed) { console.log( @@ -167,6 +172,35 @@ Examples: }, ); +program + .command('flow') + .alias('f') + .description( + 'Stage, preview, create, and submit an AI-assisted DubStack change', + ) + .option('-a, --all', 'Stage all changes before generating metadata') + .option( + '-u, --update', + 'Stage tracked file changes before generating metadata', + ) + .option('-p, --patch', 'Pick hunks to stage before generating metadata') + .option('-y, --yes', 'Auto-approve generated metadata without prompting') + .option('-i, --ai', 'Force AI flow for this invocation') + .option('--no-ai', 'Disable AI flow for this invocation') + .option( + '--dry-run', + 'Preview generated metadata without creating or submitting', + ) + .addHelpText( + 'after', + ` +Examples: + $ dub flow --ai -a Stage all, preview AI metadata, create, and submit + $ dub flow -y -u Auto-approve after staging tracked changes + $ dub flow --dry-run Preview generated branch, commit, and PR text only`, + ) + .action(runFlow); + program .command('log') .alias('l') @@ -580,6 +614,8 @@ program 'Push branches and create/update GitHub PRs for the current stack', ) .option('--dry-run', 'Print what would happen without executing') + .option('-i, --ai', 'AI-generate a PR description for this invocation') + .option('--no-ai', 'Disable AI PR description generation for this invocation') .option( '--path ', 'Submit scope: current (default) or stack', @@ -593,6 +629,7 @@ program Examples: $ dub submit Push and create/update PRs $ dub submit --dry-run Preview what would happen + $ dub submit --ai Generate a PR description before updating the PR body $ dub submit --path stack --fix Submit full stack with safe auto-remediation`, ) .action(runSubmit); @@ -601,6 +638,8 @@ program .command('ss') .description('Submit the current stack (alias for submit)') .option('--dry-run', 'Print what would happen without executing') + .option('-i, --ai', 'AI-generate a PR description for this invocation') + .option('--no-ai', 'Disable AI PR description generation for this invocation') .option( '--path ', 'Submit scope: current (default) or stack', @@ -926,6 +965,39 @@ program ); } }), + ) + .addCommand( + new Command('ai-defaults') + .description('Manage repo-local AI defaults for DubStack commands') + .argument('', 'One of: create, submit, flow') + .argument('[state]', 'Set to on/off (omit to inspect current value)') + .action(async (target: 'create' | 'submit' | 'flow', state?: string) => { + const { configAiDefaults } = await import('./commands/config'); + const result = await configAiDefaults(process.cwd(), target, state); + + if (!state) { + console.log( + chalk.blue( + `AI default for '${target}' is ${result.enabled ? 'enabled' : 'disabled'} for this repository.`, + ), + ); + return; + } + + if (result.changed) { + console.log( + chalk.green( + `✔ AI default for '${target}' ${result.enabled ? 'enabled' : 'disabled'}`, + ), + ); + } else { + console.log( + chalk.yellow( + `⚠ AI default for '${target}' is already ${result.enabled ? 'enabled' : 'disabled'}`, + ), + ); + } + }), ); program @@ -1086,10 +1158,14 @@ program async function runSubmit(options: { dryRun?: boolean; + ai?: boolean; + noAi?: boolean; path?: SubmitPathMode; fix?: boolean; }) { const result = await submit(process.cwd(), options.dryRun ?? false, { + ai: options.ai, + noAi: options.noAi, path: options.path ?? 'current', fix: options.fix ?? false, }); @@ -1115,6 +1191,44 @@ async function runSubmit(options: { } } +async function runFlow(options: { + all?: boolean; + update?: boolean; + patch?: boolean; + yes?: boolean; + ai?: boolean; + noAi?: boolean; + dryRun?: boolean; +}) { + const result = await flow(process.cwd(), { + all: options.all, + update: options.update, + patch: options.patch, + yes: options.yes, + ai: options.ai, + noAi: options.noAi, + dryRun: options.dryRun, + }); + + if (result.aborted) { + console.log(chalk.yellow('⚠ Flow cancelled before create/submit.')); + return; + } + + if (result.dryRun) { + console.log( + chalk.green( + `✔ Dry-run complete: ${result.branch} • ${result.commitMessage}`, + ), + ); + return; + } + + console.log( + chalk.green(`✔ Flow complete: ${result.branch} • ${result.commitMessage}`), + ); +} + async function printLog( cwd: string, options: { stack?: boolean; all?: boolean; reverse?: boolean } = {}, diff --git a/packages/cli/src/lib/ai-diff-context.test.ts b/packages/cli/src/lib/ai-diff-context.test.ts new file mode 100644 index 0000000..7276991 --- /dev/null +++ b/packages/cli/src/lib/ai-diff-context.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { buildAiDiffContext, categorizeDiffPath } from './ai-diff-context'; + +describe('categorizeDiffPath', () => { + it('classifies repository paths using runtime-first heuristics', () => { + expect(categorizeDiffPath('packages/cli/src/commands/flow.ts')).toBe( + 'runtime', + ); + expect(categorizeDiffPath('packages/cli/src/commands/flow.test.ts')).toBe( + 'tests', + ); + expect(categorizeDiffPath('README.md')).toBe('docs'); + expect(categorizeDiffPath('.agents/skills/dub-flow/SKILL.md')).toBe( + 'skills', + ); + expect(categorizeDiffPath('docs/plans/2026-03-08-plan.md')).toBe('plans'); + expect(categorizeDiffPath('evalite.config.ts')).toBe('config'); + expect(categorizeDiffPath('pnpm-lock.yaml')).toBe('generated'); + }); +}); + +describe('buildAiDiffContext', () => { + it('ranks runtime implementation files ahead of docs and skills in mixed diffs', () => { + const context = buildAiDiffContext({ + rawDiff: `diff --git a/README.md b/README.md +index 1111111..2222222 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,8 @@ ++# Updated docs +diff --git a/.agents/skills/dub-flow/SKILL.md b/.agents/skills/dub-flow/SKILL.md +index 1111111..2222222 100644 +--- a/.agents/skills/dub-flow/SKILL.md ++++ b/.agents/skills/dub-flow/SKILL.md +@@ -1,3 +1,8 @@ ++Use dub flow --ai +diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -140,3 +140,12 @@ export async function flow() {} ++const important = 'runtime change'; +diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.test.ts ++++ b/packages/cli/src/commands/flow.test.ts +@@ -1,3 +1,7 @@ ++it('covers the runtime change', () => {}); +`, + }); + + expect(context.dominantCategory).toBe('runtime'); + expect(context.importantFiles[0]?.path).toBe( + 'packages/cli/src/commands/flow.ts', + ); + expect(context.promptPacket).toContain( + 'Headline guidance: Runtime or product behavior changed.', + ); + }); + + it('falls back to ranked excerpts when the full diff budget is exceeded', () => { + const context = buildAiDiffContext( + { + rawDiff: `diff --git a/README.md b/README.md +index 1111111..2222222 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,8 @@ ++# Updated docs +${'docs line\n'.repeat(4_000)} +diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -140,3 +140,12 @@ export async function flow() {} ++const important = 'runtime change'; +${'runtime line\n'.repeat(2_000)} +`, + }, + { + fullDiffCharBudget: 1_000, + excerptCharBudget: 25_000, + }, + ); + + expect(context.usesFullDiff).toBe(false); + expect(context.promptPacket).toContain('RANKED_DIFF_EXCERPTS_START'); + expect(context.promptPacket).toContain('packages/cli/src/commands/flow.ts'); + expect(context.promptPacket).toContain('README.md'); + expect( + context.promptPacket.indexOf('packages/cli/src/commands/flow.ts'), + ).toBeLessThan(context.promptPacket.indexOf('README.md (+1 -0)')); + }); +}); diff --git a/packages/cli/src/lib/ai-diff-context.ts b/packages/cli/src/lib/ai-diff-context.ts new file mode 100644 index 0000000..339b5c3 --- /dev/null +++ b/packages/cli/src/lib/ai-diff-context.ts @@ -0,0 +1,501 @@ +import { redactSensitiveText } from './history'; + +export type AiDiffFileCategory = + | 'runtime' + | 'tests' + | 'docs' + | 'skills' + | 'plans' + | 'config' + | 'generated' + | 'other'; + +export interface AiDiffStatEntry { + path: string; + additions: number; + deletions: number; +} + +export interface AiDiffContextInput { + rawDiff: string; + filePaths?: string[]; + diffStats?: AiDiffStatEntry[]; +} + +export interface RankedAiDiffFile extends AiDiffStatEntry { + category: AiDiffFileCategory; + weight: number; + diff: string; +} + +export interface AiDiffContext { + rawDiff: string; + files: string[]; + diffStats: AiDiffStatEntry[]; + totalAdditions: number; + totalDeletions: number; + broadChange: boolean; + dominantCategory: AiDiffFileCategory; + importantFiles: RankedAiDiffFile[]; + promptPacket: string; + usesFullDiff: boolean; +} + +interface DiffSection { + path: string; + diff: string; + additions: number; + deletions: number; +} + +interface BuildAiDiffContextOptions { + fullDiffCharBudget?: number; + excerptCharBudget?: number; + maxImportantFiles?: number; +} + +const DEFAULT_FULL_DIFF_CHAR_BUDGET = 300_000; +const DEFAULT_EXCERPT_CHAR_BUDGET = 140_000; +const DEFAULT_MAX_IMPORTANT_FILES = 16; + +const CATEGORY_PRIORITY: AiDiffFileCategory[] = [ + 'runtime', + 'config', + 'tests', + 'docs', + 'skills', + 'plans', + 'generated', + 'other', +]; + +const CATEGORY_HEADLINE_GUIDANCE: Record = { + runtime: + 'Runtime or product behavior changed. Prefer this in the branch and commit headline.', + config: + 'Configuration or tooling changed. Use this in the headline when runtime behavior is not the primary change.', + tests: + 'Tests changed. Mention them in the PR description and use them in the headline only when they are the primary work.', + docs: 'Docs changed. Keep them in supporting context unless the change set is documentation-led.', + skills: + 'Agent skill content changed. Keep it supporting unless the change set is primarily skills.', + plans: + 'Plan or design docs changed. Keep them supporting unless planning is the primary work.', + generated: + 'Generated or lockfile changes are supporting context unless they are the main purpose of the change.', + other: + 'Miscellaneous files changed. Use them as supporting context unless they are clearly the main work.', +}; + +export function buildAiDiffContext( + input: AiDiffContextInput, + options: BuildAiDiffContextOptions = {}, +): AiDiffContext { + const fullDiffCharBudget = + options.fullDiffCharBudget ?? DEFAULT_FULL_DIFF_CHAR_BUDGET; + const excerptCharBudget = + options.excerptCharBudget ?? DEFAULT_EXCERPT_CHAR_BUDGET; + const maxImportantFiles = + options.maxImportantFiles ?? DEFAULT_MAX_IMPORTANT_FILES; + const redactedRawDiff = redactSensitiveText(input.rawDiff).trim(); + const parsedSections = parseDiffSections(redactedRawDiff); + const fallbackFiles = parsedSections.map((section) => section.path); + const files = normalizeFilePaths(input.filePaths ?? fallbackFiles); + const diffStats = mergeDiffStats( + files, + input.diffStats ?? [], + parsedSections, + ); + const importantFiles = buildRankedFiles(diffStats, parsedSections).slice( + 0, + Math.max(maxImportantFiles, CATEGORY_PRIORITY.length), + ); + const totalAdditions = diffStats.reduce( + (sum, entry) => sum + entry.additions, + 0, + ); + const totalDeletions = diffStats.reduce( + (sum, entry) => sum + entry.deletions, + 0, + ); + const representedCategories = CATEGORY_PRIORITY.filter((category) => + diffStats.some((entry) => categorizeDiffPath(entry.path) === category), + ); + const broadChange = representedCategories.length >= 3; + const dominantCategory = determineDominantCategory(importantFiles); + const usesFullDiff = redactedRawDiff.length <= fullDiffCharBudget; + const promptPacket = buildPromptPacket({ + rawDiff: redactedRawDiff, + files, + diffStats, + importantFiles, + totalAdditions, + totalDeletions, + broadChange, + dominantCategory, + usesFullDiff, + excerptCharBudget, + }); + + return { + rawDiff: redactedRawDiff, + files, + diffStats, + totalAdditions, + totalDeletions, + broadChange, + dominantCategory, + importantFiles, + promptPacket, + usesFullDiff, + }; +} + +export function categorizeDiffPath(path: string): AiDiffFileCategory { + const normalizedPath = path.replace(/\\/g, '/'); + + if ( + normalizedPath.startsWith('packages/cli/src/') || + normalizedPath.startsWith('apps/docs/app/') || + normalizedPath.startsWith('apps/docs/components/') + ) { + return isTestPath(normalizedPath) ? 'tests' : 'runtime'; + } + + if (isTestPath(normalizedPath)) { + return 'tests'; + } + + if ( + normalizedPath === 'README.md' || + normalizedPath === 'QUICKSTART.md' || + normalizedPath.startsWith('apps/docs/') || + normalizedPath.startsWith('docs/') + ) { + return normalizedPath.startsWith('docs/plans/') ? 'plans' : 'docs'; + } + + if ( + normalizedPath.startsWith('skills/') || + normalizedPath.startsWith('.agents/skills/') + ) { + return 'skills'; + } + + if ( + normalizedPath === 'package.json' || + normalizedPath === 'pnpm-lock.yaml' || + normalizedPath === 'turbo.json' || + normalizedPath === 'tsconfig.json' || + normalizedPath === 'biome.json' || + normalizedPath === '.nvmrc' || + normalizedPath.endsWith('.config.ts') || + normalizedPath.endsWith('.config.js') || + normalizedPath.endsWith('.config.mjs') || + normalizedPath.endsWith('.config.cjs') + ) { + return normalizedPath === 'pnpm-lock.yaml' ? 'generated' : 'config'; + } + + if ( + normalizedPath.startsWith('dist/') || + normalizedPath.startsWith('coverage/') || + normalizedPath.endsWith('.snap') + ) { + return 'generated'; + } + + return 'other'; +} + +function isTestPath(path: string): boolean { + return ( + path.includes('/test/') || + path.includes('/__tests__/') || + path.endsWith('.test.ts') || + path.endsWith('.test.tsx') || + path.endsWith('.spec.ts') || + path.endsWith('.spec.tsx') + ); +} + +function parseDiffSections(rawDiff: string): DiffSection[] { + if (rawDiff.length === 0) return []; + + return rawDiff + .split(/(?=^diff --git )/m) + .map((section) => section.trim()) + .filter(Boolean) + .map((section) => { + const header = section.split('\n', 1)[0] ?? ''; + const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(header); + const path = normalizeDiffPath(match?.[2] ?? match?.[1] ?? 'unknown'); + let additions = 0; + let deletions = 0; + + for (const line of section.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) continue; + if (line.startsWith('+')) additions += 1; + else if (line.startsWith('-')) deletions += 1; + } + + return { + path, + diff: section, + additions, + deletions, + }; + }); +} + +function normalizeDiffPath(path: string): string { + return path.replace(/^["']|["']$/g, '').replace(/^b\//, ''); +} + +function normalizeFilePaths(paths: string[]): string[] { + return Array.from( + new Set(paths.map((path) => path.trim()).filter((path) => path.length > 0)), + ); +} + +function mergeDiffStats( + files: string[], + providedStats: AiDiffStatEntry[], + parsedSections: DiffSection[], +): AiDiffStatEntry[] { + const providedByPath = new Map( + providedStats.map((entry) => [entry.path, entry] as const), + ); + const parsedByPath = new Map( + parsedSections.map((section) => [section.path, section] as const), + ); + + return files.map((path) => { + const provided = providedByPath.get(path); + if (provided) { + return { + path, + additions: provided.additions, + deletions: provided.deletions, + }; + } + + const parsed = parsedByPath.get(path); + return { + path, + additions: parsed?.additions ?? 0, + deletions: parsed?.deletions ?? 0, + }; + }); +} + +function buildRankedFiles( + diffStats: AiDiffStatEntry[], + parsedSections: DiffSection[], +): RankedAiDiffFile[] { + const diffByPath = new Map( + parsedSections.map((section) => [section.path, section.diff] as const), + ); + + return diffStats + .map((entry) => { + const category = categorizeDiffPath(entry.path); + return { + ...entry, + category, + diff: diffByPath.get(entry.path) ?? '', + weight: scoreFile( + entry.path, + category, + entry.additions + entry.deletions, + ), + }; + }) + .sort((a, b) => b.weight - a.weight || a.path.localeCompare(b.path)); +} + +function determineDominantCategory( + files: RankedAiDiffFile[], +): AiDiffFileCategory { + const scores = new Map(); + + for (const file of files) { + const current = scores.get(file.category) ?? 0; + scores.set( + file.category, + current + scoreCategory(file.category, file.additions + file.deletions), + ); + } + + let bestCategory: AiDiffFileCategory = 'other'; + let bestScore = -1; + + for (const category of CATEGORY_PRIORITY) { + const score = scores.get(category) ?? 0; + if (score > bestScore) { + bestCategory = category; + bestScore = score; + } + } + + return bestCategory; +} + +function scoreFile( + path: string, + category: AiDiffFileCategory, + lineCount: number, +): number { + let score = scoreCategory(category, lineCount); + + if (path.startsWith('packages/cli/src/commands/')) score += 240; + else if (path.startsWith('packages/cli/src/lib/')) score += 200; + else if (path.startsWith('packages/cli/src/')) score += 160; + else if (path.startsWith('packages/cli/test/')) score += 120; + else if (path.startsWith('apps/docs/content/docs/')) score += 40; + + return score; +} + +function scoreCategory( + category: AiDiffFileCategory, + lineCount: number, +): number { + const normalizedLineCount = Math.min(lineCount, 400); + const base = + category === 'runtime' + ? 1_000 + : category === 'config' + ? 700 + : category === 'tests' + ? 500 + : category === 'docs' + ? 220 + : category === 'skills' + ? 180 + : category === 'plans' + ? 120 + : category === 'generated' + ? 80 + : 160; + + return base + normalizedLineCount; +} + +function buildPromptPacket(input: { + rawDiff: string; + files: string[]; + diffStats: AiDiffStatEntry[]; + importantFiles: RankedAiDiffFile[]; + totalAdditions: number; + totalDeletions: number; + broadChange: boolean; + dominantCategory: AiDiffFileCategory; + usesFullDiff: boolean; + excerptCharBudget: number; +}): string { + const manifestLines = input.diffStats.map((entry) => { + const category = categorizeDiffPath(entry.path); + return `- [${category}] ${entry.path} (+${entry.additions} -${entry.deletions})`; + }); + const importantLines = input.importantFiles.map((file) => { + return `- [${file.category}] ${file.path} (+${file.additions} -${file.deletions}) weight=${file.weight}`; + }); + const categorySummaries = CATEGORY_PRIORITY.map((category) => { + const files = input.diffStats.filter( + (entry) => categorizeDiffPath(entry.path) === category, + ); + if (files.length === 0) return null; + return `- ${category}: ${files.length} files (${files.map((file) => file.path).join(', ')})`; + }).filter(Boolean); + + const diffSection = input.usesFullDiff + ? buildFullDiffSection(input.rawDiff) + : buildExcerptSection(input.importantFiles, input.excerptCharBudget); + + return [ + 'CHANGESET_OVERVIEW_START', + `Total files: ${input.files.length}`, + `Total line delta: +${input.totalAdditions} -${input.totalDeletions}`, + `Broad change: ${input.broadChange ? 'yes' : 'no'}`, + `Dominant category: ${input.dominantCategory}`, + `Headline guidance: ${CATEGORY_HEADLINE_GUIDANCE[input.dominantCategory]}`, + 'CATEGORY_BREAKDOWN_START', + ...categorySummaries, + 'CATEGORY_BREAKDOWN_END', + 'IMPORTANT_FILES_START', + ...importantLines, + 'IMPORTANT_FILES_END', + 'FULL_FILE_MANIFEST_START', + ...manifestLines, + 'FULL_FILE_MANIFEST_END', + diffSection, + 'CHANGESET_OVERVIEW_END', + ] + .filter(Boolean) + .join('\n'); +} + +function buildFullDiffSection(rawDiff: string): string { + return [ + 'FULL_REDACTED_DIFF_START', + rawDiff.length > 0 ? rawDiff : '[No textual diff available]', + 'FULL_REDACTED_DIFF_END', + ].join('\n'); +} + +function buildExcerptSection( + importantFiles: RankedAiDiffFile[], + excerptCharBudget: number, +): string { + const selectedFiles = selectFilesForExcerptBudget( + importantFiles, + excerptCharBudget, + ); + const lines = ['RANKED_DIFF_EXCERPTS_START']; + + for (const file of selectedFiles) { + lines.push(`FILE_START ${file.path} [${file.category}]`); + lines.push(truncate(file.diff || '[No textual diff available]', 20_000)); + lines.push(`FILE_END ${file.path}`); + } + + lines.push('RANKED_DIFF_EXCERPTS_END'); + return lines.join('\n'); +} + +function selectFilesForExcerptBudget( + importantFiles: RankedAiDiffFile[], + excerptCharBudget: number, +): RankedAiDiffFile[] { + const selected: RankedAiDiffFile[] = []; + const seen = new Set(); + let remaining = excerptCharBudget; + + for (const category of CATEGORY_PRIORITY) { + const match = importantFiles.find((file) => file.category === category); + if (!match || seen.has(match.path)) continue; + const diffLength = match.diff.length; + if (selected.length > 0 && diffLength > remaining && remaining < 2_000) { + continue; + } + selected.push(match); + seen.add(match.path); + remaining -= Math.min(diffLength, 20_000); + } + + for (const file of importantFiles) { + if (seen.has(file.path)) continue; + if (remaining < 2_000) break; + selected.push(file); + seen.add(file.path); + remaining -= Math.min(file.diff.length, 20_000); + } + + return selected; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}\n...[truncated]`; +} diff --git a/packages/cli/src/lib/ai-metadata.test.ts b/packages/cli/src/lib/ai-metadata.test.ts new file mode 100644 index 0000000..46ab15b --- /dev/null +++ b/packages/cli/src/lib/ai-metadata.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAiDiffContext } from './ai-diff-context'; +import { + generateCreateMetadata, + generateFlowMetadata, + generatePrDescriptionSummary, +} from './ai-metadata'; +import { DubError } from './errors'; + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; +}); + +afterEach(() => { + process.env = envSnapshot; +}); + +describe('generateCreateMetadata', () => { + it('uses the Gemini provider when DUBSTACK_GEMINI_API_KEY is set', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example","message":"feat: example"}', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await generateCreateMetadata('diff --git a/file b/file', { + generateText, + createGoogleGenerativeAI, + createGateway, + }); + + expect(result).toEqual({ + branch: 'feat/example', + message: 'feat: example', + }); + expect(createGoogleGenerativeAI).toHaveBeenCalledWith({ + apiKey: 'gem-key', + }); + expect(createGateway).not.toHaveBeenCalled(); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'google-model', + }), + ); + }); + + it('falls back to the gateway provider when only the gateway key is set', async () => { + delete process.env.DUBSTACK_GEMINI_API_KEY; + process.env.DUBSTACK_AI_GATEWAY_API_KEY = 'gateway-key'; + + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example","message":"feat: example"}', + }); + const createGoogleGenerativeAI = vi.fn(); + const gatewayModel = vi.fn().mockReturnValue('gateway-model'); + const createGateway = vi.fn().mockReturnValue(gatewayModel); + + await generateCreateMetadata('diff --git a/file b/file', { + generateText, + createGoogleGenerativeAI, + createGateway, + }); + + expect(createGoogleGenerativeAI).not.toHaveBeenCalled(); + expect(createGateway).toHaveBeenCalledWith({ apiKey: 'gateway-key' }); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gateway-model', + }), + ); + }); + + it('throws when no AI provider keys are configured', async () => { + delete process.env.DUBSTACK_GEMINI_API_KEY; + delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; + + await expect( + generateCreateMetadata('diff --git a/file b/file', { + generateText: vi.fn(), + createGoogleGenerativeAI: vi.fn(), + createGateway: vi.fn(), + }), + ).rejects.toThrow(DubError); + + await expect( + generateCreateMetadata('diff --git a/file b/file', { + generateText: vi.fn(), + createGoogleGenerativeAI: vi.fn(), + createGateway: vi.fn(), + }), + ).rejects.toThrow('DUBSTACK_GEMINI_API_KEY'); + }); + + it('throws when AI metadata is missing required fields', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + await expect( + generateCreateMetadata('diff --git a/file b/file', { + generateText: vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example"}', + }), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }), + ).rejects.toThrow("AI assistant metadata is missing 'message'."); + }); + + it('includes the commit template in the create prompt when provided', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example","message":"feat: example\\n\\n## Testing\\n- [x] added"}', + }); + + await generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText, + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + { + commitTemplate: 'feat(scope): summary\n\n## Testing\n- [ ] added', + }, + ); + + const call = vi.mocked(generateText).mock.calls[0]?.[0]; + expect(String(call?.prompt ?? '')).toContain( + 'REPOSITORY_COMMIT_TEMPLATE_START', + ); + expect(String(call?.prompt ?? '')).toContain('## Testing'); + }); + + it('includes structured staged context so late runtime files are still emphasized', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + const generateText = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/flow-context","message":"feat: improve flow context"}', + }); + + await generateCreateMetadata( + buildAiDiffContext({ + rawDiff: `diff --git a/README.md b/README.md +index 1111111..2222222 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,7 @@ ++Updated docs +diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts +index 1111111..2222222 100644 +--- a/packages/cli/src/commands/flow.ts ++++ b/packages/cli/src/commands/flow.ts +@@ -1,3 +1,9 @@ ++const runtime = 'change'; +`, + }), + { + generateText, + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + ); + + const call = vi.mocked(generateText).mock.calls[0]?.[0]; + const prompt = String(call?.prompt ?? ''); + expect(prompt).toContain('Dominant category: runtime'); + expect(prompt).toContain('FULL_FILE_MANIFEST_START'); + expect(prompt).toContain('packages/cli/src/commands/flow.ts'); + }); + + it('normalizes extra whitespace in the commit subject while preserving the body', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + const result = await generateCreateMetadata('diff --git a/file b/file', { + generateText: vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example","message":"feat: example subject\\n\\n## Testing\\n- [x] added"}', + }), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }); + + expect(result.message).toBe( + 'feat: example subject\n\n## Testing\n- [x] added', + ); + }); +}); + +describe('generatePrDescriptionSummary', () => { + it('generates a markdown summary with the configured provider', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + const generateText = vi.fn().mockResolvedValue({ + text: '## Summary\n\nAdds the new submit AI flow.', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await generatePrDescriptionSummary( + { + branch: 'feat/submit-ai', + baseBranch: 'main', + commitMessage: 'feat: add submit ai mode', + diff: 'diff --git a/file b/file', + }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result).toBe('## Summary\n\nAdds the new submit AI flow.'); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'google-model', + }), + ); + }); + + it('rejects empty AI PR descriptions', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + await expect( + generatePrDescriptionSummary( + { + branch: 'feat/submit-ai', + baseBranch: 'main', + commitMessage: 'feat: add submit ai mode', + diff: 'diff --git a/file b/file', + }, + { + generateText: vi.fn().mockResolvedValue({ text: ' ' }), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + ), + ).rejects.toThrow('AI assistant generated an empty PR description.'); + }); + + it('includes the pull request template in the PR prompt when provided', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + const generateText = vi.fn().mockResolvedValue({ + text: '## Summary\n\nUses the existing template.', + }); + + await generatePrDescriptionSummary( + { + branch: 'feat/submit-ai', + baseBranch: 'main', + commitMessage: 'feat: add submit ai mode', + diff: 'diff --git a/file b/file', + }, + { + generateText, + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + { + prTemplate: '## Summary\n\n## Testing', + }, + ); + + const call = vi.mocked(generateText).mock.calls[0]?.[0]; + expect(String(call?.prompt ?? '')).toContain( + 'REPOSITORY_PR_TEMPLATE_START', + ); + expect(String(call?.prompt ?? '')).toContain('## Testing'); + }); +}); + +describe('generateFlowMetadata', () => { + it('generates branch, commit message, and pr description from the same staged diff', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + const generateText = vi + .fn() + .mockResolvedValueOnce({ + text: '{"branch":"feat/flow-example","message":"feat: add flow example"}', + }) + .mockResolvedValueOnce({ + text: '## Summary\n\nAdds the flow example.', + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + + const result = await generateFlowMetadata( + { + parentBranch: 'main', + staged: buildAiDiffContext({ rawDiff: 'diff --git a/file b/file' }), + }, + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + ); + + expect(result).toEqual({ + branch: 'feat/flow-example', + commitMessage: 'feat: add flow example', + prDescription: '## Summary\n\nAdds the flow example.', + }); + expect(generateText).toHaveBeenCalledTimes(2); + }); + + it('passes commit and pr templates through to the underlying prompts', async () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + + const generateText = vi + .fn() + .mockResolvedValueOnce({ + text: '{"branch":"feat/templated","message":"feat: templated\\n\\n## Testing\\n- [x] added"}', + }) + .mockResolvedValueOnce({ + text: '## Summary\n\n## Testing\n- [x] added', + }); + + await generateFlowMetadata( + { + parentBranch: 'main', + staged: buildAiDiffContext({ rawDiff: 'diff --git a/file b/file' }), + }, + { + generateText, + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + { + commitTemplate: 'feat(scope): summary\n\n## Testing\n- [ ] added', + prTemplate: '## Summary\n\n## Testing', + }, + ); + + const createCall = vi.mocked(generateText).mock.calls[0]?.[0]; + const prCall = vi.mocked(generateText).mock.calls[1]?.[0]; + expect(String(createCall?.prompt ?? '')).toContain( + 'REPOSITORY_COMMIT_TEMPLATE_START', + ); + expect(String(prCall?.prompt ?? '')).toContain( + 'REPOSITORY_PR_TEMPLATE_START', + ); + }); +}); diff --git a/packages/cli/src/lib/ai-metadata.ts b/packages/cli/src/lib/ai-metadata.ts new file mode 100644 index 0000000..9ec2fe3 --- /dev/null +++ b/packages/cli/src/lib/ai-metadata.ts @@ -0,0 +1,306 @@ +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createGateway, generateText, LanguageModel } from 'ai'; +import { + type AiDiffContext, + type AiDiffContextInput, + buildAiDiffContext, +} from './ai-diff-context'; +import { DubError } from './errors'; + +export interface AiMetadataDependencies { + generateText: typeof generateText; + createGoogleGenerativeAI: typeof createGoogleGenerativeAI; + createGateway: typeof createGateway; +} + +export interface PrDescriptionContext { + branch: string; + baseBranch: string; + commitMessage: string; + diff: AiDiffContext | string | AiDiffContextInput; +} + +export interface AiMetadataTemplates { + prTemplate?: string | null; + commitTemplate?: string | null; +} + +export interface FlowMetadataInput { + parentBranch: string; + staged: AiDiffContext; +} + +const CONVENTIONAL_COMMIT_RE = + /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/; + +export async function generateCreateMetadata( + stagedDiff: AiDiffContext | string | AiDiffContextInput, + deps: AiMetadataDependencies, + templates: AiMetadataTemplates = {}, +): Promise<{ branch: string; message: string }> { + const resolved = resolveModel(deps); + const diffContext = resolveAiDiffContext(stagedDiff); + const prompt = [ + 'Generate a git branch name and conventional commit message for the entire staged change set.', + 'Return JSON only, exactly like: {"branch":"feat/your-branch","message":"feat: summary"}', + 'Rules:', + '- consider the entire staged change set, not just the first files in the diff.', + '- choose the branch and commit headline based on the dominant implementation change.', + '- runtime or product-behavior changes outrank tests, docs, skills, and plans when multiple categories are present.', + '- docs, tests, skills, and plans should usually stay in the commit body or supporting context unless they are the primary work.', + '- branch must be lowercase, slash-delimited, and kebab-case.', + '- message must be a Conventional Commit subject line.', + '- choose the branch/message type that matches the dominant change intent: feat for new functionality, fix for bug fixes, refactor for internal cleanup, docs for documentation-only changes, and test for test-only changes.', + '- if a repository commit template is provided, preserve its structure in the generated commit message body.', + '- keep message under 72 characters when possible.', + '- do not include markdown fences.', + '', + ...buildTemplatePromptSection( + 'REPOSITORY_COMMIT_TEMPLATE', + templates.commitTemplate, + ), + ...((templates.commitTemplate?.trim().length ?? 0) > 0 ? [''] : []), + 'STAGED_CHANGE_CONTEXT_START', + diffContext.promptPacket, + 'STAGED_CHANGE_CONTEXT_END', + ].join('\n'); + + const result = await deps.generateText({ + model: resolved.model, + system: + 'You produce concise git metadata. Output strict JSON only and never add extra commentary.', + prompt, + }); + + return parseAiCreateResponse(result.text); +} + +export async function generatePrDescriptionSummary( + context: PrDescriptionContext, + deps: AiMetadataDependencies, + templates: AiMetadataTemplates = {}, +): Promise { + const resolved = resolveModel(deps); + const diffContext = resolveAiDiffContext(context.diff); + const prompt = [ + 'Write a concise pull request description in markdown.', + 'Rules:', + '- Consider the entire change set, not just the first files in git diff output.', + '- Lead with the dominant implementation change when runtime or product behavior changed.', + '- Still cover materially changed docs, tests, skills, plans, or config in supporting sections when they are part of the change set.', + '- Do not include a title line; the PR title is managed separately.', + '- Do not include HTML comments or markdown fences.', + '- Keep it readable in a terminal markdown preview.', + '- If a repository PR template is provided, keep its headings and section order.', + '', + `Branch: ${context.branch}`, + `Base branch: ${context.baseBranch}`, + `Commit message: ${context.commitMessage}`, + '', + ...buildTemplatePromptSection( + 'REPOSITORY_PR_TEMPLATE', + templates.prTemplate, + ), + ...((templates.prTemplate?.trim().length ?? 0) > 0 ? [''] : []), + 'BRANCH_CHANGESET_CONTEXT_START', + diffContext.promptPacket, + 'BRANCH_CHANGESET_CONTEXT_END', + ].join('\n'); + + const result = await deps.generateText({ + model: resolved.model, + system: + 'You write concise pull request descriptions in markdown. Return markdown only with no extra commentary.', + prompt, + }); + + const summary = result.text.trim(); + if (summary.length === 0) { + throw new DubError('AI assistant generated an empty PR description.'); + } + + return stripMarkdownFences(summary); +} + +export async function generateFlowMetadata( + input: FlowMetadataInput, + deps: AiMetadataDependencies, + templates: AiMetadataTemplates = {}, +): Promise<{ + branch: string; + commitMessage: string; + prDescription: string; +}> { + const generated = await generateCreateMetadata(input.staged, deps, templates); + const prDescription = await generatePrDescriptionSummary( + { + branch: generated.branch, + baseBranch: input.parentBranch, + commitMessage: generated.message, + diff: input.staged, + }, + deps, + templates, + ); + + return { + branch: generated.branch, + commitMessage: generated.message, + prDescription, + }; +} + +function resolveModel(deps: AiMetadataDependencies): { + provider: 'google' | 'gateway'; + model: LanguageModel; + modelId: string; +} { + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (geminiApiKey) { + const geminiModel = + process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; + const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); + return { + provider: 'google', + model: google(geminiModel), + modelId: geminiModel, + }; + } + + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (gatewayApiKey) { + const gatewayModel = + process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; + const gateway = deps.createGateway({ apiKey: gatewayApiKey }); + return { + provider: 'gateway', + model: gateway(gatewayModel), + modelId: gatewayModel, + }; + } + + throw new DubError( + "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", + ); +} + +function parseAiCreateResponse(text: string): { + branch: string; + message: string; +} { + const candidate = extractJsonObject(text); + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + + const rawBranch = getStringValue(parsed, 'branch'); + const rawMessage = getStringValue(parsed, 'message'); + const branch = normalizeBranchName(rawBranch); + const message = normalizeCommitMessage(rawMessage); + const subjectLine = message.split('\n')[0]?.trim() ?? ''; + + if (branch.length === 0) { + throw new DubError('AI assistant generated an empty branch name.'); + } + + if (!CONVENTIONAL_COMMIT_RE.test(subjectLine)) { + throw new DubError( + "AI assistant generated a non-conventional commit message. Re-run '--ai' or pass '-m' manually.", + ); + } + + return { branch, message }; +} + +function getStringValue(source: object, key: string): string { + const value = (source as Record)[key]; + if (typeof value !== 'string') { + throw new DubError(`AI assistant metadata is missing '${key}'.`); + } + return value; +} + +function normalizeBranchName(value: string): string { + return value + .trim() + .replace(/^`+|`+$/g, '') + .replace(/^refs\/heads\//, '') + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9./_-]+/g, '-') + .replace(/\/+/g, '/') + .replace(/-+/g, '-') + .replace(/^\/+|\/+$/g, '') + .replace(/^\.+/, '') + .replace(/\.+$/, ''); +} + +function normalizeCommitMessage(value: string): string { + const normalized = value + .trim() + .replace(/^`+|`+$/g, '') + .replace(/\r\n/g, '\n'); + const [subjectLine = '', ...bodyLines] = normalized.split('\n'); + const subject = subjectLine.replace(/\s+/g, ' ').trim(); + const body = bodyLines.join('\n').trim(); + return body.length > 0 ? `${subject}\n\n${body}` : subject; +} + +function extractJsonObject(text: string): string { + const trimmed = text.trim(); + const withoutFences = stripMarkdownFences(trimmed); + const start = withoutFences.indexOf('{'); + const end = withoutFences.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) { + throw new DubError( + "AI assistant returned invalid metadata. Re-run with '--ai' or pass branch/message manually.", + ); + } + return withoutFences.slice(start, end + 1); +} + +function stripMarkdownFences(text: string): string { + const trimmed = text.trim(); + if (!trimmed.startsWith('```') || !trimmed.endsWith('```')) { + return trimmed; + } + + return trimmed + .replace(/^```(?:\w+)?\s*/i, '') + .replace(/\s*```$/, '') + .trim(); +} + +function buildTemplatePromptSection( + label: string, + template: string | null | undefined, +): string[] { + const trimmed = template?.trim(); + if (!trimmed) return []; + return [`${label}_START`, trimmed, `${label}_END`]; +} + +function resolveAiDiffContext( + value: AiDiffContext | string | AiDiffContextInput, +): AiDiffContext { + if (typeof value === 'string') { + return buildAiDiffContext({ rawDiff: value }); + } + + if ('promptPacket' in value) { + return value; + } + + return buildAiDiffContext(value); +} diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index 341f0b0..3182c57 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -24,6 +24,11 @@ describe('readConfig', () => { expect(config).toEqual({ aiAssistantEnabled: false, ai: { + defaults: { + createMetadata: false, + submitDescription: false, + flow: false, + }, shortcutFallback: { enabled: true, typoGuard: 'interactive', @@ -58,5 +63,33 @@ describe('writeConfig', () => { await writeConfig({ aiAssistantEnabled: true }, dir); const config = await readConfig(dir); expect(config.aiAssistantEnabled).toBe(true); + expect(config.ai.defaults).toEqual({ + createMetadata: false, + submitDescription: false, + flow: false, + }); + }); + + it('fills in missing ai defaults when persisting partial config', async () => { + await writeConfig( + { + ai: { + defaults: { + createMetadata: true, + submitDescription: false, + flow: false, + }, + }, + }, + dir, + ); + + const config = await readConfig(dir); + + expect(config.ai.defaults).toEqual({ + createMetadata: true, + submitDescription: false, + flow: false, + }); }); }); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 02044e8..299e1d2 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -6,6 +6,11 @@ import { getDubDir } from './state'; export interface DubConfig { aiAssistantEnabled: boolean; ai: { + defaults: { + createMetadata: boolean; + submitDescription: boolean; + flow: boolean; + }; shortcutFallback: { enabled: boolean; typoGuard: 'interactive'; @@ -24,9 +29,21 @@ export interface DubConfig { }; } +type DeepPartial = + T extends Array + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + const DEFAULT_CONFIG: DubConfig = { aiAssistantEnabled: false, ai: { + defaults: { + createMetadata: false, + submitDescription: false, + flow: false, + }, shortcutFallback: { enabled: true, typoGuard: 'interactive', @@ -58,7 +75,7 @@ export async function readConfig(cwd: string): Promise { try { const raw = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as DeepPartial; return normalizeConfig(parsed); } catch { throw new DubError( @@ -68,7 +85,7 @@ export async function readConfig(cwd: string): Promise { } export async function writeConfig( - config: Partial, + config: DeepPartial, cwd: string, ): Promise { const configPath = await getConfigPath(cwd); @@ -80,7 +97,8 @@ export async function writeConfig( fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`); } -function normalizeConfig(config: Partial): DubConfig { +function normalizeConfig(config: DeepPartial): DubConfig { + const defaults = config.ai?.defaults; const fallback = config.ai?.shortcutFallback; const shellHistory = config.ai?.context?.shellHistory; const webBrowsing = config.ai?.webBrowsing; @@ -91,6 +109,20 @@ function normalizeConfig(config: Partial): DubConfig { ? config.aiAssistantEnabled : DEFAULT_CONFIG.aiAssistantEnabled, ai: { + defaults: { + createMetadata: + typeof defaults?.createMetadata === 'boolean' + ? defaults.createMetadata + : DEFAULT_CONFIG.ai.defaults.createMetadata, + submitDescription: + typeof defaults?.submitDescription === 'boolean' + ? defaults.submitDescription + : DEFAULT_CONFIG.ai.defaults.submitDescription, + flow: + typeof defaults?.flow === 'boolean' + ? defaults.flow + : DEFAULT_CONFIG.ai.defaults.flow, + }, shortcutFallback: { enabled: typeof fallback?.enabled === 'boolean' diff --git a/packages/cli/src/lib/git.test.ts b/packages/cli/src/lib/git.test.ts index 8c5d9ef..cfa17b9 100644 --- a/packages/cli/src/lib/git.test.ts +++ b/packages/cli/src/lib/git.test.ts @@ -6,11 +6,13 @@ import { DubError } from './errors'; import { branchExists, commitStaged, + commitStagedFromFile, createBranch, deleteBranch, forceBranchTo, getBranchTip, getCurrentBranch, + getDiffBetween, getMergeBase, hasStagedChanges, isGitRepo, @@ -184,6 +186,14 @@ describe('getMergeBase', () => { }); }); +describe('getDiffBetween', () => { + it('throws when either ref is invalid', async () => { + await expect(getDiffBetween('main', 'missing-branch', dir)).rejects.toThrow( + DubError, + ); + }); +}); + describe('getBranchTip', () => { it('returns the commit SHA of a branch', async () => { const expected = ( @@ -256,3 +266,26 @@ describe('commitStaged', () => { await expect(commitStaged('empty commit', dir)).rejects.toThrow(DubError); }); }); + +describe('commitStagedFromFile', () => { + it('creates a commit using a file-backed message', async () => { + const messagePath = path.join(dir, 'commit-message.md'); + fs.writeFileSync(messagePath, 'test: add file from message file\n'); + fs.writeFileSync(path.join(dir, 'commit-file.txt'), 'data'); + await gitInRepo(dir, ['add', 'commit-file.txt']); + + await commitStagedFromFile(messagePath, dir); + + const { stdout } = await gitInRepo(dir, ['log', '-1', '--format=%s']); + expect(stdout.trim()).toBe('test: add file from message file'); + }); + + it('throws when the message file commit fails', async () => { + const messagePath = path.join(dir, 'commit-message.md'); + fs.writeFileSync(messagePath, 'test: no staged changes\n'); + + await expect(commitStagedFromFile(messagePath, dir)).rejects.toThrow( + DubError, + ); + }); +}); diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index 60b4b55..340ba85 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -1,6 +1,12 @@ import { execa } from 'execa'; import { DubError } from './errors'; +export interface DiffStatEntry { + path: string; + additions: number; + deletions: number; +} + /** * Checks whether the given directory is inside a git repository. * @returns `true` if inside a git worktree, `false` otherwise. Never throws. @@ -353,6 +359,23 @@ export async function commitStaged( } } +/** + * Commits currently staged changes using a file-backed message. + * @throws {DubError} If the commit fails. + */ +export async function commitStagedFromFile( + filePath: string, + cwd: string, +): Promise { + try { + await execa('git', ['commit', '--file', filePath], { cwd }); + } catch { + throw new DubError( + 'Commit failed. Ensure there are staged changes and git hooks pass.', + ); + } +} + /** * Commits currently staged changes, opening the editor if no message is provided. * @param cwd - The working directory. @@ -470,6 +493,82 @@ export async function getDiff(cwd: string, staged: boolean): Promise { } } +/** + * Returns changed file paths for a diff. + */ +export async function getDiffFileNames( + cwd: string, + staged: boolean, +): Promise { + try { + const args = ['diff', '--name-only']; + if (staged) args.push('--cached'); + const { stdout } = await execa('git', args, { cwd }); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +/** + * Returns per-file line stats for a diff. + */ +export async function getDiffNumStat( + cwd: string, + staged: boolean, +): Promise { + try { + const args = ['diff', '--numstat']; + if (staged) args.push('--cached'); + const { stdout } = await execa('git', args, { cwd }); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [rawAdditions = '0', rawDeletions = '0', path = ''] = + line.split('\t'); + return { + path, + additions: parseDiffCount(rawAdditions), + deletions: parseDiffCount(rawDeletions), + }; + }) + .filter((entry) => entry.path.length > 0); + } catch { + return []; + } +} + +/** + * Returns the diff between two refs using merge-base three-dot semantics. + */ +export async function getDiffBetween( + baseRef: string, + headRef: string, + cwd: string, +): Promise { + try { + const { stdout } = await execa('git', ['diff', `${baseRef}...${headRef}`], { + cwd, + }); + return stdout; + } catch { + throw new DubError( + `Failed to diff '${headRef}' against '${baseRef}'. Verify both refs exist and are reachable.`, + ); + } +} + +function parseDiffCount(raw: string): number { + if (raw === '-') return 0; + const value = Number(raw); + return Number.isFinite(value) ? value : 0; +} + /** * Returns a list of all local branch names. */ diff --git a/packages/cli/src/lib/metadata-templates.test.ts b/packages/cli/src/lib/metadata-templates.test.ts new file mode 100644 index 0000000..2b25e53 --- /dev/null +++ b/packages/cli/src/lib/metadata-templates.test.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { execa } from 'execa'; +import { readMetadataTemplates } from './metadata-templates'; + +const mockExeca = execa as unknown as MockInstance; + +let dir: string; + +beforeEach(async () => { + dir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'dubstack-templates-'), + ); + vi.clearAllMocks(); +}); + +afterEach(async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); +}); + +describe('readMetadataTemplates', () => { + it('loads the default pull request template from .github', async () => { + await fs.promises.mkdir(path.join(dir, '.github'), { recursive: true }); + await fs.promises.writeFile( + path.join(dir, '.github', 'pull_request_template.md'), + '## Summary\n\n- item\n', + ); + mockExeca.mockRejectedValueOnce(new Error('no commit template')); + + const result = await readMetadataTemplates(dir); + + expect(result.prTemplate).toContain('## Summary'); + expect(result.commitTemplate).toBeNull(); + }); + + it('loads a pull request template from .github/PULL_REQUEST_TEMPLATE', async () => { + await fs.promises.mkdir( + path.join(dir, '.github', 'PULL_REQUEST_TEMPLATE'), + { + recursive: true, + }, + ); + await fs.promises.writeFile( + path.join(dir, '.github', 'PULL_REQUEST_TEMPLATE', 'feature.md'), + '## Feature template\n', + ); + mockExeca.mockRejectedValueOnce(new Error('no commit template')); + + const result = await readMetadataTemplates(dir); + + expect(result.prTemplate).toContain('## Feature template'); + }); + + it('loads the configured commit template from git config', async () => { + await fs.promises.writeFile( + path.join(dir, '.gitmessage'), + 'feat(scope): summary\n\n## Testing\n- [ ] added\n', + ); + mockExeca.mockResolvedValueOnce({ + stdout: '.gitmessage\n', + }); + + const result = await readMetadataTemplates(dir); + + expect(mockExeca).toHaveBeenCalledWith( + 'git', + ['config', '--get', 'commit.template'], + { cwd: dir }, + ); + expect(result.commitTemplate).toContain('## Testing'); + expect(result.prTemplate).toBeNull(); + }); + + it('returns null templates when nothing is configured', async () => { + mockExeca.mockRejectedValueOnce(new Error('not set')); + + const result = await readMetadataTemplates(dir); + + expect(result).toEqual({ + prTemplate: null, + commitTemplate: null, + }); + }); +}); diff --git a/packages/cli/src/lib/metadata-templates.ts b/packages/cli/src/lib/metadata-templates.ts new file mode 100644 index 0000000..5186e5f --- /dev/null +++ b/packages/cli/src/lib/metadata-templates.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execa } from 'execa'; + +export interface MetadataTemplates { + prTemplate: string | null; + commitTemplate: string | null; +} + +const PR_TEMPLATE_CANDIDATES = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'pull_request_template.md', +] as const; + +export async function readMetadataTemplates( + cwd: string, +): Promise { + const prTemplate = await readPullRequestTemplate(cwd); + const commitTemplate = await readCommitTemplate(cwd); + return { + prTemplate, + commitTemplate, + }; +} + +async function readPullRequestTemplate(cwd: string): Promise { + for (const relativePath of PR_TEMPLATE_CANDIDATES) { + const fullPath = path.join(cwd, relativePath); + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + return fs.readFileSync(fullPath, 'utf8'); + } + } + + const templateDir = path.join(cwd, '.github', 'PULL_REQUEST_TEMPLATE'); + if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) { + return null; + } + + const candidates = fs + .readdirSync(templateDir) + .filter((entry) => fs.statSync(path.join(templateDir, entry)).isFile()) + .sort((a, b) => a.localeCompare(b)); + + if (candidates.length === 0) return null; + + return fs.readFileSync(path.join(templateDir, candidates[0]), 'utf8'); +} + +async function readCommitTemplate(cwd: string): Promise { + try { + const { stdout } = await execa( + 'git', + ['config', '--get', 'commit.template'], + { + cwd, + }, + ); + const configuredPath = stdout.trim(); + if (configuredPath.length === 0) return null; + + const resolvedPath = path.isAbsolute(configuredPath) + ? configuredPath + : path.join(cwd, configuredPath); + if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { + return null; + } + return fs.readFileSync(resolvedPath, 'utf8'); + } catch { + return null; + } +} diff --git a/packages/cli/src/lib/pr-body.test.ts b/packages/cli/src/lib/pr-body.test.ts index fa85ac0..ab51ab8 100644 --- a/packages/cli/src/lib/pr-body.test.ts +++ b/packages/cli/src/lib/pr-body.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + buildAiSummarySection, buildMetadataBlock, buildStackTable, composePrBody, parseDubstackMetadata, + stripAiSummarySection, stripDubstackSections, } from './pr-body'; import type { Branch } from './state'; @@ -111,25 +113,130 @@ describe('stripDubstackSections', () => { }); describe('composePrBody', () => { - it('combines user content with stack sections', () => { - const result = composePrBody('My PR', 'STACK_TABLE', 'META_BLOCK'); + it('combines user content with ai summary and stack sections', () => { + const result = composePrBody( + 'My PR', + 'AI summary', + 'STACK_TABLE', + 'META_BLOCK', + ); - expect(result).toBe('My PR\n\nSTACK_TABLE\n\nMETA_BLOCK'); + expect(result).toBe( + [ + 'My PR', + buildAiSummarySection('AI summary'), + 'STACK_TABLE', + 'META_BLOCK', + ].join('\n\n'), + ); }); - it('strips stale sections before composing', () => { - const existingBody = - 'My PR\n\n\nold table\n\n\n'; + it('replaces stale ai summary and dubstack sections before composing', () => { + const existingBody = [ + 'My PR', + buildAiSummarySection('Old summary'), + '', + 'old table', + '', + '', + '', + ].join('\n'); - const result = composePrBody(existingBody, 'NEW_TABLE', 'NEW_META'); + const result = composePrBody( + existingBody, + 'New summary', + 'NEW_TABLE', + 'NEW_META', + ); - expect(result).toBe('My PR\n\nNEW_TABLE\n\nNEW_META'); + expect(result).toBe( + [ + 'My PR', + buildAiSummarySection('New summary'), + 'NEW_TABLE', + 'NEW_META', + ].join('\n\n'), + ); }); it('handles empty existing body', () => { - const result = composePrBody('', 'TABLE', 'META'); + const result = composePrBody('', 'Summary', 'TABLE', 'META'); + + expect(result).toBe( + [buildAiSummarySection('Summary'), 'TABLE', 'META'].join('\n\n'), + ); + }); + + it('preserves user-authored content around ai-managed sections', () => { + const existingBody = [ + 'User intro', + '', + buildAiSummarySection('Old summary'), + '', + 'Extra author note', + '', + '', + 'old table', + '', + '', + '', + ].join('\n'); - expect(result).toBe('TABLE\n\nMETA'); + const result = composePrBody( + existingBody, + 'Fresh summary', + 'TABLE', + 'META', + ); + + expect(result).toContain('User intro\n\nExtra author note'); + expect(result).toContain(buildAiSummarySection('Fresh summary')); + expect(result).toContain('TABLE'); + expect(result).toContain('META'); + }); +}); + +describe('AI summary helpers', () => { + it('wraps ai summary content in replaceable markers', () => { + const result = buildAiSummarySection('Summary text'); + + expect(result).toContain(''); + expect(result).toContain('Summary text'); + expect(result).toContain(''); + }); + + it('strips only the ai-managed summary section', () => { + const body = [ + 'User intro', + '', + buildAiSummarySection('Generated summary'), + '', + 'User footer', + ].join('\n'); + + expect(stripAiSummarySection(body)).toBe('User intro\n\nUser footer'); + }); + + it('strips duplicate ai-managed summary sections without leaving stale text behind', () => { + const body = [ + 'User intro', + '', + buildAiSummarySection('Generated summary'), + '', + 'User middle', + '', + buildAiSummarySection('Older generated summary'), + '', + 'User footer', + ].join('\n'); + + expect(stripAiSummarySection(body)).toBe( + 'User intro\n\nUser middle\n\nUser footer', + ); }); }); @@ -137,6 +244,7 @@ describe('parseDubstackMetadata', () => { it('parses metadata block from a composed PR body', () => { const body = composePrBody( 'My description', + '', 'STACK', buildMetadataBlock('stack-1', 12, 11, 13, 'feat/a'), ); diff --git a/packages/cli/src/lib/pr-body.ts b/packages/cli/src/lib/pr-body.ts index 8ad38a2..4f31fbb 100644 --- a/packages/cli/src/lib/pr-body.ts +++ b/packages/cli/src/lib/pr-body.ts @@ -7,6 +7,8 @@ interface StackEntry { const DUBSTACK_START = ''; const DUBSTACK_END = ''; +const AI_SUMMARY_START = ''; +const AI_SUMMARY_END = ''; const METADATA_START = ''; @@ -96,23 +98,62 @@ export function stripDubstackSections(body: string): string { return result.trimEnd(); } +/** + * Wraps an AI-managed PR summary in explicit markers so it can be replaced safely. + */ +export function buildAiSummarySection(summary: string): string { + const trimmed = summary.trim(); + if (trimmed.length === 0) return ''; + return [AI_SUMMARY_START, trimmed, AI_SUMMARY_END].join('\n'); +} + +/** + * Removes only the AI-managed summary section while preserving user-authored text. + */ +export function stripAiSummarySection(body: string): string { + let result = body; + + while (true) { + const startIdx = result.indexOf(AI_SUMMARY_START); + const endIdx = result.indexOf(AI_SUMMARY_END); + + if (startIdx === -1 || endIdx === -1) { + return normalizeBodyWhitespace(result); + } + + result = + result.slice(0, startIdx) + result.slice(endIdx + AI_SUMMARY_END.length); + } +} + /** * Composes the final PR body by combining user content with DubStack sections. * * @param existingBody - The existing PR body (may contain stale DubStack sections) + * @param aiSummary - Human-readable AI-generated summary content * @param stackTable - Output of `buildStackTable` * @param metadataBlock - Output of `buildMetadataBlock` */ export function composePrBody( existingBody: string, + aiSummary: string, stackTable: string, metadataBlock: string, ): string { - const userContent = stripDubstackSections(existingBody); - const parts = [userContent, stackTable, metadataBlock].filter(Boolean); + const userContent = stripAiSummarySection( + stripDubstackSections(existingBody), + ); + const aiSection = buildAiSummarySection(aiSummary); + const parts = [userContent, aiSection, stackTable, metadataBlock].filter( + Boolean, + ); return parts.join('\n\n'); } +function normalizeBodyWhitespace(body: string): string { + return body.trim().replace(/\n{3,}/g, '\n\n'); +} + /** * Parses hidden DubStack metadata from a PR body. * Returns null when metadata markers are absent or malformed. diff --git a/packages/cli/src/lib/temp-text-file.test.ts b/packages/cli/src/lib/temp-text-file.test.ts new file mode 100644 index 0000000..a1872d0 --- /dev/null +++ b/packages/cli/src/lib/temp-text-file.test.ts @@ -0,0 +1,37 @@ +import * as fs from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { withTempMarkdownFile } from './temp-text-file'; + +describe('withTempMarkdownFile', () => { + it('writes markdown content to a temp file for the callback', async () => { + let capturedPath = ''; + + const result = await withTempMarkdownFile( + 'preview', + '# Heading\n\nBody\n', + async (filePath) => { + capturedPath = filePath; + expect(filePath.endsWith('.md')).toBe(true); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, 'utf8')).toBe('# Heading\n\nBody\n'); + return 'done'; + }, + ); + + expect(result).toBe('done'); + expect(fs.existsSync(capturedPath)).toBe(false); + }); + + it('cleans up the temp file when the callback throws', async () => { + let capturedPath = ''; + + await expect( + withTempMarkdownFile('preview', 'Body\n', async (filePath) => { + capturedPath = filePath; + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(fs.existsSync(capturedPath)).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/temp-text-file.ts b/packages/cli/src/lib/temp-text-file.ts new file mode 100644 index 0000000..1fe5e38 --- /dev/null +++ b/packages/cli/src/lib/temp-text-file.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export function writeTempMarkdownFile(prefix: string, content: string): string { + const filePath = path.join( + os.tmpdir(), + `dubstack-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`, + ); + fs.writeFileSync(filePath, content); + return filePath; +} + +export function removeTempFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // Best-effort cleanup. + } +} + +export async function withTempMarkdownFile( + prefix: string, + content: string, + run: (filePath: string) => Promise, +): Promise { + const filePath = writeTempMarkdownFile(prefix, content); + try { + return await run(filePath); + } finally { + removeTempFile(filePath); + } +} diff --git a/packages/cli/src/lib/terminal-render.test.ts b/packages/cli/src/lib/terminal-render.test.ts new file mode 100644 index 0000000..261a22f --- /dev/null +++ b/packages/cli/src/lib/terminal-render.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { createTerminalRenderer } from './terminal-render'; + +function createCapture(isTTY: boolean) { + const writes: string[] = []; + return { + writes, + output: { + isTTY, + write(value: string | Uint8Array) { + writes.push(typeof value === 'string' ? value : value.toString()); + return true; + }, + }, + }; +} + +describe('createTerminalRenderer', () => { + it('renders markdown headings, quotes, code fences, and tables in TTY mode', () => { + const capture = createCapture(true); + const renderer = createTerminalRenderer(capture.output); + + renderer.renderMarkdown( + [ + '# Summary', + '', + '> quoted context', + '', + '```ts', + 'const value = 1;', + '```', + '', + '| Name | Value |', + '| --- | --- |', + '| foo | 1 |', + ].join('\n'), + ); + + const rendered = capture.writes.join(''); + expect(rendered).toContain('Summary'); + expect(rendered).not.toContain('# Summary'); + expect(rendered).toContain('│ quoted context'); + expect(rendered).not.toContain('```'); + expect(rendered).toContain('const value = 1;'); + expect(rendered).toContain('Name'); + expect(rendered).toContain('foo'); + }); + + it('keeps markdown plain in non-tty mode', () => { + const capture = createCapture(false); + const renderer = createTerminalRenderer(capture.output); + + renderer.renderMarkdown('# Summary\n\n- item'); + + expect(capture.writes.join('')).toBe('# Summary\n\n- item\n'); + }); + + it('renders preview panels and tool activity lines', () => { + const capture = createCapture(true); + const renderer = createTerminalRenderer(capture.output); + + renderer.renderPreview('PR Description', '## Changes\n\n- add AI summary'); + renderer.renderToolActivity('bash', 'git status --short'); + + const rendered = capture.writes.join(''); + expect(rendered).toContain('PR Description'); + expect(rendered).toContain('Changes'); + expect(rendered).toContain('AI: running bash'); + expect(rendered).toContain('git status --short'); + }); +}); diff --git a/packages/cli/src/lib/terminal-render.ts b/packages/cli/src/lib/terminal-render.ts new file mode 100644 index 0000000..82234fb --- /dev/null +++ b/packages/cli/src/lib/terminal-render.ts @@ -0,0 +1,139 @@ +import chalk from 'chalk'; + +interface WritableLike { + write: (chunk: string | Uint8Array) => unknown; + isTTY?: boolean; +} + +export interface TerminalRenderer { + renderMarkdown: (markdown: string) => void; + renderPreview: (title: string, markdown: string) => void; + renderStatus: (status: string) => void; + renderToolActivity: (toolName: string, detail?: string) => void; +} + +export function createTerminalRenderer(output: WritableLike): TerminalRenderer { + const isTTY = output.isTTY === true; + + const writeBlock = (text: string) => { + if (text.trim().length === 0) return; + output.write(`${text}\n`); + }; + + return { + renderMarkdown(markdown: string) { + writeBlock(formatMarkdown(markdown, { isTTY })); + }, + renderPreview(title: string, markdown: string) { + const heading = isTTY ? chalk.bold(title) : title; + const divider = isTTY + ? chalk.dim('─'.repeat(title.length)) + : '-'.repeat(title.length); + const body = formatMarkdown(markdown, { isTTY }); + writeBlock([heading, divider, body].filter(Boolean).join('\n')); + }, + renderStatus(status: string) { + const trimmed = status.trim(); + if (trimmed.length === 0) return; + const line = `AI: ${trimmed}`; + const styled = isTTY ? chalk.cyan(line) : line; + writeBlock(styled); + }, + renderToolActivity(toolName: string, detail?: string) { + const normalizedTool = toolName.trim() || 'tool'; + const trimmedDetail = detail?.trim(); + const base = `AI: running ${normalizedTool}`; + const line = + trimmedDetail && trimmedDetail.length > 0 + ? `${base} ${trimmedDetail}` + : base; + writeBlock(isTTY ? chalk.yellow(line) : line); + }, + }; +} + +function formatMarkdown(markdown: string, options: { isTTY: boolean }): string { + const trimmed = markdown.trimEnd(); + if (trimmed.length === 0) return ''; + if (!options.isTTY) return trimmed; + + const lines = trimmed.split('\n'); + const output: string[] = []; + let inCodeFence = false; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmedLine = line.trim(); + + if (trimmedLine.startsWith('```')) { + inCodeFence = !inCodeFence; + continue; + } + + if (inCodeFence) { + output.push(chalk.cyan(` ${line}`)); + continue; + } + + if (isTableLine(trimmedLine)) { + const tableLines: string[] = []; + while (index < lines.length && isTableLine(lines[index].trim())) { + tableLines.push(lines[index].trim()); + index += 1; + } + index -= 1; + output.push(...formatTable(tableLines)); + continue; + } + + const headingMatch = /^(#{1,6})\s+(.+)$/.exec(trimmedLine); + if (headingMatch) { + output.push(chalk.bold(headingMatch[2])); + continue; + } + + if (trimmedLine.startsWith('>')) { + output.push(chalk.dim(`│ ${trimmedLine.slice(1).trim()}`)); + continue; + } + + output.push(line); + } + + return output.join('\n'); +} + +function isTableLine(line: string): boolean { + return line.startsWith('|') && line.endsWith('|'); +} + +function formatTable(lines: string[]): string[] { + const rows = lines + .filter((line) => !/^\|\s*[: -]+(\|\s*[: -]+)+\|$/.test(line)) + .map((line) => + line + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()), + ); + + if (rows.length === 0) return []; + + const widths = rows.reduce((current, row) => { + row.forEach((cell, index) => { + current[index] = Math.max(current[index] ?? 0, cell.length); + }); + return current; + }, []); + + return rows.map((row, index) => { + const formatted = row + .map((cell, cellIndex) => cell.padEnd(widths[cellIndex] ?? cell.length)) + .join(' | '); + if (index === 0) { + const divider = widths.map((width) => '─'.repeat(width)).join('─┼─'); + return `${chalk.bold(formatted)}\n${chalk.dim(divider)}`; + } + return formatted; + }); +} diff --git a/packages/cli/test/commands/flow.test.ts b/packages/cli/test/commands/flow.test.ts new file mode 100644 index 0000000..07b2fef --- /dev/null +++ b/packages/cli/test/commands/flow.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flow } from '../../src/commands/flow'; +import { writeConfig } from '../../src/lib/config'; +import { getCurrentBranch } from '../../src/lib/git'; +import { createTestRepo, gitInRepo } from '../helpers'; + +let dir: string; +let cleanup: () => Promise; + +function createRenderer() { + return { + renderMarkdown: vi.fn(), + renderPreview: vi.fn(), + renderStatus: vi.fn(), + renderToolActivity: vi.fn(), + }; +} + +beforeEach(async () => { + const repo = await createTestRepo(); + dir = repo.dir; + cleanup = repo.cleanup; +}); + +afterEach(async () => { + await cleanup(); +}); + +describe('flow integration', () => { + it('creates the branch, commits staged content from file, and submits with the generated summary override', async () => { + await writeConfig( + { + aiAssistantEnabled: true, + ai: { + defaults: { + createMetadata: false, + submitDescription: false, + flow: true, + }, + }, + }, + dir, + ); + + fs.writeFileSync( + path.join(dir, 'flow-feature.ts'), + 'export const flowFeature = true;\n', + ); + + const submit = vi.fn().mockResolvedValue({ + pushed: ['feat/flow-real'], + created: ['feat/flow-real'], + updated: [], + path: 'current', + dryRun: false, + fallbackApplied: false, + }); + + const result = await flow( + dir, + { + all: true, + yes: true, + }, + { + generateFlowMetadata: vi.fn().mockResolvedValue({ + branch: 'feat/flow-real', + commitMessage: 'feat: add real flow coverage', + prDescription: '## Summary\n\nAdds real flow coverage.', + }), + readMetadataTemplates: vi.fn().mockResolvedValue({ + prTemplate: null, + commitTemplate: null, + }), + createTerminalRenderer: vi.fn().mockReturnValue(createRenderer()), + submit, + }, + ); + + expect(result.aborted).toBe(false); + expect(await getCurrentBranch(dir)).toBe('feat/flow-real'); + + const { stdout: subject } = await gitInRepo(dir, [ + 'log', + '-1', + '--format=%s', + ]); + expect(subject.trim()).toBe('feat: add real flow coverage'); + + const { stdout: trackedFile } = await gitInRepo(dir, [ + 'show', + 'HEAD:flow-feature.ts', + ]); + expect(trackedFile).toContain('flowFeature = true'); + + expect(submit).toHaveBeenCalledWith(dir, false, { + path: 'current', + fix: false, + summaryOverrides: new Map([ + ['feat/flow-real', '## Summary\n\nAdds real flow coverage.'], + ]), + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b69af1f..c2fd4bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@biomejs/biome': specifier: ^2.4.2 version: 2.4.2 + evalite: + specifier: 0.19.0 + version: 0.19.0 turbo: specifier: ^2.5.0 version: 2.8.14 @@ -139,6 +142,10 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.1': + resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -444,6 +451,36 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@8.3.0': + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + + '@fastify/websocket@11.2.0': + resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -661,6 +698,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -677,6 +718,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -762,6 +807,9 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1314,6 +1362,13 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stricli/auto-complete@1.2.6': + resolution: {integrity: sha512-H7dectwnLBoyDrp4Vek1gTNdUWzqkEDt5X6oFoOPxPVbca5FA9ttBZ/OlfNvt14aeiZUsg1rC7GEHjIh3tjn2A==} + hasBin: true + + '@stricli/core@1.2.6': + resolution: {integrity: sha512-j5fa1wyOLrP9WJqqLFEJeQviqb3cK46K+FXTuISEkG/H5C820YfKDoVQ3CDVdM5WLhEx1ARdpiW6+hU4tYHjCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1573,6 +1628,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1589,10 +1647,29 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + amdefine@1.0.1: resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} engines: {node: '>=0.4.2'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1613,10 +1690,21 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -1645,6 +1733,12 @@ packages: just-bash: optional: true + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1723,6 +1817,13 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1752,6 +1853,14 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1784,6 +1893,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1802,6 +1915,12 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1827,6 +1946,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1860,6 +1982,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + evalite@0.19.0: + resolution: {integrity: sha512-tR9HJFJaqzzcjeqKklCRIWuGTK4g5VtOtKSC4cu5DfG3eDLfruRkz+tuSmYJjBVGp5vQe1M85/Hq0ALpeWx5sw==} + hasBin: true + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -1883,16 +2009,31 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -1900,6 +2041,12 @@ packages: resolution: {integrity: sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==} hasBin: true + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.2: + resolution: {integrity: sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1916,17 +2063,32 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-type@19.6.0: + resolution: {integrity: sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==} + engines: {node: '>=18'} + file-type@21.3.0: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + framer-motion@12.35.1: resolution: {integrity: sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==} peerDependencies: @@ -2074,6 +2236,12 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2124,6 +2292,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -2149,6 +2321,10 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2166,6 +2342,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2204,6 +2384,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2223,6 +2407,12 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -2234,6 +2424,9 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -2319,9 +2512,16 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lucide-react@0.577.0: resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} peerDependencies: @@ -2505,6 +2705,11 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -2516,6 +2721,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2627,6 +2836,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2636,6 +2849,9 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -2657,12 +2873,20 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peek-readable@5.4.2: + resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} + engines: {node: '>=14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2674,6 +2898,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2721,6 +2955,12 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -2734,6 +2974,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2798,6 +3041,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -2845,6 +3092,10 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2852,10 +3103,17 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2867,6 +3125,13 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2877,11 +3142,20 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2911,10 +3185,17 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + smol-toml@1.6.0: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2926,6 +3207,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2938,15 +3223,30 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} @@ -2966,6 +3266,10 @@ packages: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} + strtok3@9.1.1: + resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==} + engines: {node: '>=16'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2994,6 +3298,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -3018,6 +3326,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3040,6 +3352,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -3344,6 +3664,10 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider@2.0.1': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -3520,6 +3844,57 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@8.3.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 0.5.4 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 11.1.0 + + '@fastify/websocket@11.2.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 5.1.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3677,6 +4052,8 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 + '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3696,6 +4073,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -3776,6 +4155,8 @@ snapshots: '@orama/orama@3.1.18': {} + '@pinojs/redact@0.4.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4267,6 +4648,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stricli/auto-complete@1.2.6': + dependencies: + '@stricli/core': 1.2.6 + + '@stricli/core@1.2.6': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4500,6 +4887,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4514,8 +4903,25 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.6 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + amdefine@1.0.1: {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} argparse@1.0.10: @@ -4536,14 +4942,22 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + astral-regex@2.0.0: {} + astring@1.9.0: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + bail@2.0.2: {} balanced-match@4.0.3: {} - base64-js@1.5.1: - optional: true + base64-js@1.5.1: {} baseline-browser-mapping@2.10.0: {} @@ -4556,12 +4970,20 @@ snapshots: optionalDependencies: just-bash: 2.10.2 + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - optional: true brace-expansion@5.0.2: dependencies: @@ -4575,7 +4997,6 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - optional: true bundle-require@5.1.0(esbuild@0.27.3): dependencies: @@ -4608,8 +5029,7 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: - optional: true + chownr@1.1.4: {} class-variance-authority@0.7.1: dependencies: @@ -4623,6 +5043,12 @@ snapshots: collapse-white-space@2.1.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} commander@14.0.3: {} @@ -4644,6 +5070,12 @@ snapshots: consola@3.4.2: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4665,10 +5097,10 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 - optional: true - deep-extend@0.6.0: - optional: true + deep-extend@0.6.0: {} + + depd@2.0.0: {} dequal@2.0.3: {} @@ -4682,10 +5114,18 @@ snapshots: diff@8.0.3: {} + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + emoji-regex@8.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 - optional: true enhanced-resolve@5.20.0: dependencies: @@ -4739,6 +5179,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -4780,6 +5222,25 @@ snapshots: dependencies: '@types/estree': 1.0.8 + evalite@0.19.0: + dependencies: + '@ai-sdk/provider': 2.0.1 + '@fastify/static': 8.3.0 + '@fastify/websocket': 11.2.0 + '@stricli/auto-complete': 1.2.6 + '@stricli/core': 1.2.6 + '@vitest/runner': 4.0.18 + '@vitest/utils': 4.0.18 + better-sqlite3: 11.10.0 + fastify: 5.8.2 + file-type: 19.6.0 + jiti: 2.6.1 + table: 6.9.0 + tinyrainbow: 3.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventsource-parser@3.0.6: {} execa@9.6.1: @@ -4797,8 +5258,7 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expand-template@2.0.3: - optional: true + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -4808,6 +5268,10 @@ snapshots: extend@3.0.2: {} + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4816,12 +5280,27 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 @@ -4830,6 +5309,26 @@ snapshots: dependencies: strnum: 2.1.2 + fastify-plugin@5.1.0: {} + + fastify@5.8.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4842,6 +5341,13 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-type@19.6.0: + dependencies: + get-stream: 9.0.1 + strtok3: 9.1.1 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + file-type@21.3.0: dependencies: '@tokenizer/inflate': 0.4.1 @@ -4851,16 +5357,29 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.57.1 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + framer-motion@12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.35.1 @@ -4870,8 +5389,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - fs-constants@1.0.0: - optional: true + fs-constants@1.0.0: {} fsevents@2.3.3: optional: true @@ -4991,8 +5509,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-from-package@0.0.0: - optional: true + github-from-package@0.0.0: {} github-slugger@2.0.0: {} @@ -5000,6 +5517,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.2 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + graceful-fs@4.2.11: {} graceful-readlink@1.0.1: {} @@ -5129,22 +5655,30 @@ snapshots: html-void-elements@3.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-signals@8.0.1: {} ieee754@1.2.1: {} image-size@2.0.2: {} - inherits@2.0.4: - optional: true + inherits@2.0.4: {} - ini@1.3.8: - optional: true + ini@1.3.8: {} ini@6.0.0: {} inline-style-parser@0.2.7: {} + ipaddr.js@2.3.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -5158,6 +5692,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5187,6 +5723,10 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jiti@2.6.1: {} joycon@3.1.1: {} @@ -5202,6 +5742,12 @@ snapshots: dependencies: argparse: 2.0.1 + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} just-bash@2.10.2: @@ -5231,6 +5777,12 @@ snapshots: kind-of@6.0.3: {} + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.31.1: optional: true @@ -5286,8 +5838,12 @@ snapshots: load-tsconfig@0.2.5: {} + lodash.truncate@4.4.2: {} + longest-streak@3.1.0: {} + lru-cache@11.2.6: {} + lucide-react@0.577.0(react@19.2.4): dependencies: react: 19.2.4 @@ -5744,18 +6300,19 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mimic-response@3.1.0: - optional: true + mime@3.0.0: {} + + mimic-response@3.1.0: {} minimatch@10.2.2: dependencies: brace-expansion: 5.0.2 - minimist@1.2.8: - optional: true + minimist@1.2.8: {} - mkdirp-classic@0.5.3: - optional: true + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} mlly@1.8.0: dependencies: @@ -5792,8 +6349,7 @@ snapshots: nanoid@3.3.11: {} - napi-build-utils@2.0.0: - optional: true + napi-build-utils@2.0.0: {} negotiator@1.0.0: {} @@ -5830,7 +6386,6 @@ snapshots: node-abi@3.87.0: dependencies: semver: 7.7.4 - optional: true node-addon-api@8.5.0: optional: true @@ -5855,10 +6410,11 @@ snapshots: obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 - optional: true oniguruma-parser@0.12.1: {} @@ -5868,6 +6424,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + package-json-from-dist@1.0.1: {} + papaparse@5.5.3: {} parse-entities@4.0.2: @@ -5890,16 +6448,43 @@ snapshots: path-key@4.0.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + path-to-regexp@8.3.0: {} pathe@2.0.3: {} + peek-readable@5.4.2: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -5948,19 +6533,21 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 - optional: true pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + property-information@7.1.0: {} pump@3.0.3: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - optional: true pyodide@0.27.7: dependencies: @@ -5971,13 +6558,14 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - optional: true re2js@1.2.2: {} @@ -6025,12 +6613,13 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - optional: true readdirp@4.1.2: {} readdirp@5.0.0: {} + real-require@0.2.0: {} + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -6134,12 +6723,18 @@ snapshots: transitivePeerDependencies: - supports-color + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -6175,8 +6770,13 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-buffer@5.2.1: - optional: true + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} scheduler@0.27.0: {} @@ -6189,8 +6789,14 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + secure-json-parse@4.1.0: {} + semver@7.7.4: {} + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -6244,24 +6850,34 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: - optional: true + simple-concat@1.0.1: {} simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 - optional: true + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 smol-toml@1.6.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: {} @@ -6270,24 +6886,36 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stream-shift@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - optional: true stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-bom-string@1.0.0: {} strip-final-newline@4.0.0: {} - strip-json-comments@2.0.1: - optional: true + strip-json-comments@2.0.1: {} strnum@2.1.2: {} @@ -6295,6 +6923,11 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + strtok3@9.1.1: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 5.4.2 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -6322,6 +6955,14 @@ snapshots: dependencies: has-flag: 4.0.0 + table@6.9.0: + dependencies: + ajv: 8.18.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {} @@ -6334,7 +6975,6 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.3 tar-stream: 2.2.0 - optional: true tar-stream@2.2.0: dependencies: @@ -6343,7 +6983,6 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 - optional: true thenify-all@1.6.0: dependencies: @@ -6353,6 +6992,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6370,6 +7013,10 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.1 @@ -6424,7 +7071,6 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 - optional: true turbo-darwin-64@2.8.14: optional: true @@ -6625,8 +7271,7 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wrappy@1.0.2: - optional: true + wrappy@1.0.2: {} ws@8.19.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4917109..bfe29af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - - 'apps/*' - - 'packages/*' + - apps/* + - packages/* onlyBuiltDependencies: + - better-sqlite3 - esbuild diff --git a/skills/dub-flow/SKILL.md b/skills/dub-flow/SKILL.md index b8a37bd..9af22ac 100644 --- a/skills/dub-flow/SKILL.md +++ b/skills/dub-flow/SKILL.md @@ -5,21 +5,23 @@ description: Use when turning staged changes into a DubStack branch, commit, and # DubStack PR Flow -Use this skill when a user asks to "create a PR" or "submit this" from staged changes. +Use this skill when a user asks to "create a PR" or "submit this" from staged changes, especially when you want the CLI path to mirror `dub flow`. ## Goal Produce a clean, reviewable stack operation with: 1. suggested branch name 2. suggested commit message -3. optional issue linkage -4. execution via `dub create` and `dub submit` +3. suggested PR description +4. optional issue linkage +5. execution via `dub flow` when AI is available, or `dub create` + `dub submit` when manual mode is required ## Preconditions - Current directory is a git repo. - Staged changes exist (or user explicitly wants help staging). - `gh` auth is configured for PR operations. +- If using AI flow, `dub config ai-assistant on` is enabled for the repo. ## Phase 1: Analyze Changes @@ -75,15 +77,33 @@ If user provided issue ID (for example `A-35`), append: Completes A-35 ``` -### PR title/body guidance +### PR description guidance -Since `dub ss` manages stack submission, focus on high-quality commit messages and branch names first. If user asks to polish PR text, prepare concise title/body recommendations after submission. +- PR title should stay equal to the commit subject for squash-merge safety. +- AI-generated PR text should focus on the description body only. +- If the repo has a PR template, preserve its headings and section order. + +### Template-aware metadata + +If the repo has templates, use them as the formatting contract: + +- PR template locations: + - `.github/pull_request_template.md` + - `.github/PULL_REQUEST_TEMPLATE.md` + - `.github/PULL_REQUEST_TEMPLATE/*.md` + - `docs/pull_request_template.md` + - `pull_request_template.md` +- Commit template: + - configure with `git config commit.template .gitmessage` + +The first line of the commit message still must be a valid Conventional Commit subject. ## Phase 3: Confirm Before Execution Present: - suggested branch name - suggested commit message +- suggested PR description - what command you plan to run Ask user to choose: @@ -93,6 +113,16 @@ Ask user to choose: ## Phase 4: Execute +### Preferred AI path + +```bash +dub flow --ai -a +``` + +- `dub flow` previews branch, commit, PR, and planned commands before mutation. +- In non-interactive terminals, add `-y` because approval prompts require a TTY. +- Use `dub f --dry-run` when you only need preview output. + ### Default path (stage all) ```bash @@ -125,6 +155,8 @@ dub pr - **No staged changes**: ask user to stage files or choose `-a/-u/-p` flow. - **Branch exists already**: suggest alternate name. - **GitHub auth errors**: prompt `gh auth login`. +- **AI not enabled**: prompt `dub config ai-assistant on` and configure repo defaults if appropriate. +- **Non-interactive terminal**: rerun with `-y` or use `--dry-run` if the user only wants preview output. - **Submit conflicts/restack issues**: run `dub restack`, resolve conflicts, then rerun `dub ss`. ## Success Output Template diff --git a/skills/dubstack/SKILL.md b/skills/dubstack/SKILL.md index 1f4b985..c85eb0d 100644 --- a/skills/dubstack/SKILL.md +++ b/skills/dubstack/SKILL.md @@ -19,7 +19,9 @@ Use this skill whenever the user is working in a repo that uses `dub` for stacke | Intent | Command | |---|---| | Create branch | `dub create ` | +| AI create | `dub create --ai` | | Create + commit | `dub create -am "msg"` | +| AI full flow | `dub flow --ai -a` / `dub f --ai -a` | | Modify current branch | `dub modify` / `dub m` | | Navigate stack | `dub up`, `dub down`, `dub top`, `dub bottom` | | Interactive checkout | `dub checkout` / `dub co` | @@ -34,8 +36,11 @@ Use this skill whenever the user is working in a repo that uses `dub` for stacke | Stack-aware delete | `dub delete [branch] [--upstack|--downstack]` | | Show parent/children/trunk | `dub parent`, `dub children`, `dub trunk` | | Submit PR stack | `dub submit` / `dub ss` | +| AI PR description | `dub submit --ai` | | Open PR in browser | `dub pr [branch|number]` | | Undo last create/restack | `dub undo` | +| Ask AI assistant | `dub ai ask "..."` | +| AI conflict help | `dub ai resolve` / `dub continue --ai` | ## Command Notes @@ -54,6 +59,18 @@ dub create feat/x -pm "feat: ..." - `-p`: interactive hunk staging - staging flags require `-m` +AI create mode: + +```bash +dub create --ai +dub create -ai +dub create --no-ai feat/x +``` + +- `--ai` generates both branch name and commit message from staged changes. +- `--no-ai` overrides repo AI defaults for a single invocation. +- if a repo commit template is configured via `git config commit.template`, AI create preserves that structure in the generated commit body. + ### Modify ```bash @@ -109,6 +126,10 @@ dub abort ```bash dub ss dub submit --dry-run +dub submit --ai +dub submit --no-ai +dub flow --ai -a +dub f --dry-run ``` ```bash @@ -149,6 +170,77 @@ dub trunk 5. Iterate with `dub m ...` and `dub ss` 6. Keep updated with `dub sync` (or `dub restack` when needed) +## AI Setup + +```bash +dub ai env --gemini-key "" +# or +dub ai env --gateway-key "" + +dub config ai-assistant on +dub config ai-defaults create on +dub config ai-defaults submit on +dub config ai-defaults flow on +``` + +Precedence: + +1. command flag such as `--ai` or `--no-ai` +2. repo-local `dub config ai-defaults ...` +3. built-in fallback of off + +## AI Workflow + +Manual path: + +1. stage changes +2. run `dub create -am "type: summary"` +3. run `dub submit` + +AI-assisted path: + +1. stage changes or use `-a`, `-u`, or `-p` +2. run `dub flow --ai` +3. review the rendered branch, commit, and PR previews +4. approve or edit +5. let DubStack create and submit + +Notes: + +- In non-interactive terminals, add `-y` because `dub flow` approval prompts require a TTY. +- `dub ai ask` streams response text live and renders tool/status lines separately in TTY output. + +## Template Setup + +If a repo wants DubStack AI to follow existing formatting, set up templates first. + +Pull request templates: + +- `.github/pull_request_template.md` +- `.github/PULL_REQUEST_TEMPLATE.md` +- `.github/PULL_REQUEST_TEMPLATE/*.md` +- `docs/pull_request_template.md` +- `pull_request_template.md` + +Commit message templates: + +```bash +cat <<'EOF' > .gitmessage +feat(scope): summary + +## Testing +- [ ] added coverage +EOF + +git config commit.template .gitmessage +``` + +Notes: + +- PR titles still come from the last commit message. +- AI-generated PR descriptions preserve repo PR template structure when present. +- AI-generated commit bodies preserve repo commit template structure when present, but the first line still needs to be a valid Conventional Commit subject. + ## Recovery Patterns ### Restack conflict From 9973861500de5993bd3267a94bc6c2e56a97d2fe Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sun, 8 Mar 2026 11:23:24 -0700 Subject: [PATCH 2/2] fix(cli): address review thread regressions --- .beads/dolt-monitor.pid.lock | 0 packages/cli/src/commands/flow.test.ts | 5 ++++- packages/cli/src/commands/flow.ts | 2 +- packages/cli/src/commands/submit.test.ts | 1 + packages/cli/src/commands/submit.ts | 3 +-- packages/cli/src/lib/pr-body.test.ts | 22 ++++++++++++++++++++++ packages/cli/src/lib/pr-body.ts | 11 +++++++++-- 7 files changed, 38 insertions(+), 6 deletions(-) delete mode 100644 .beads/dolt-monitor.pid.lock diff --git a/.beads/dolt-monitor.pid.lock b/.beads/dolt-monitor.pid.lock deleted file mode 100644 index e69de29..0000000 diff --git a/packages/cli/src/commands/flow.test.ts b/packages/cli/src/commands/flow.test.ts index 5aadc84..8b2e071 100644 --- a/packages/cli/src/commands/flow.test.ts +++ b/packages/cli/src/commands/flow.test.ts @@ -49,6 +49,7 @@ describe('flow', () => { ai: { defaults: { flow: true, + createMetadata: true, }, }, }), @@ -79,7 +80,9 @@ describe('flow', () => { expect(renderer.renderPreview).toHaveBeenCalled(); expect(getDiffFileNames).toHaveBeenCalledWith('/repo', true); expect(getDiffNumStat).toHaveBeenCalledWith('/repo', true); - expect(create).toHaveBeenCalledWith('feat/flow-preview', '/repo', {}); + expect(create).toHaveBeenCalledWith('feat/flow-preview', '/repo', { + noAi: true, + }); expect(commitStagedFromFile).toHaveBeenCalledWith( expect.any(String), '/repo', diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index 770962f..2a0ef55 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -220,7 +220,7 @@ export async function flow( } } - await resolvedDeps.create(generated.branch, cwd, {}); + await resolvedDeps.create(generated.branch, cwd, { noAi: true }); await withTempMarkdownFile( 'commit-message', commitMessage, diff --git a/packages/cli/src/commands/submit.test.ts b/packages/cli/src/commands/submit.test.ts index 31aba52..49ee961 100644 --- a/packages/cli/src/commands/submit.test.ts +++ b/packages/cli/src/commands/submit.test.ts @@ -570,6 +570,7 @@ describe('submit', () => { expect(result.created).toEqual([]); expect(mockCreatePr).not.toHaveBeenCalled(); expect(mockUpdatePrBody).toHaveBeenCalled(); + expect(mockGetLastCommitMessage).not.toHaveBeenCalled(); }); it('saves pr_number and pr_link to state', async () => { diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index 5b6c940..06aafd7 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -375,7 +375,6 @@ async function updateAllPrBodies( const branch = branches[i]; const pr = prMap.get(branch.name); if (!pr) continue; - const commitMessage = await getLastCommitMessage(branch.name, cwd); const prevPr = i > 0 ? (prMap.get(branches[i - 1].name)?.number ?? null) : null; @@ -403,7 +402,7 @@ async function updateAllPrBodies( { branch: branch.name, baseBranch: branch.parent as string, - commitMessage, + commitMessage: await getLastCommitMessage(branch.name, cwd), diff: await getDiffForPrDescription( branch.name, branch.parent as string, diff --git a/packages/cli/src/lib/pr-body.test.ts b/packages/cli/src/lib/pr-body.test.ts index ab51ab8..1639be2 100644 --- a/packages/cli/src/lib/pr-body.test.ts +++ b/packages/cli/src/lib/pr-body.test.ts @@ -238,6 +238,28 @@ describe('AI summary helpers', () => { 'User intro\n\nUser middle\n\nUser footer', ); }); + + it('ignores unmatched end markers that appear before a valid ai summary block', () => { + const body = [ + 'User intro', + '', + '', + '', + buildAiSummarySection('Generated summary'), + '', + 'User footer', + ].join('\n'); + + expect(stripAiSummarySection(body)).toBe( + [ + 'User intro', + '', + '', + '', + 'User footer', + ].join('\n'), + ); + }); }); describe('parseDubstackMetadata', () => { diff --git a/packages/cli/src/lib/pr-body.ts b/packages/cli/src/lib/pr-body.ts index 4f31fbb..eed64d6 100644 --- a/packages/cli/src/lib/pr-body.ts +++ b/packages/cli/src/lib/pr-body.ts @@ -115,9 +115,16 @@ export function stripAiSummarySection(body: string): string { while (true) { const startIdx = result.indexOf(AI_SUMMARY_START); - const endIdx = result.indexOf(AI_SUMMARY_END); - if (startIdx === -1 || endIdx === -1) { + if (startIdx === -1) { + return normalizeBodyWhitespace(result); + } + + const endIdx = result.indexOf( + AI_SUMMARY_END, + startIdx + AI_SUMMARY_START.length, + ); + if (endIdx === -1) { return normalizeBodyWhitespace(result); }