ticket-to-pr 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  # TicketToPR
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/ticket-to-pr.svg)](https://www.npmjs.com/package/ticket-to-pr)
6
+
5
7
  ### Drag a Notion ticket. Get a pull request.
6
8
 
7
9
  AI-powered development automation that turns your Notion backlog into shipped code.
@@ -32,6 +34,7 @@ TicketToPR clears that pile. Toss tickets on your Notion board, drag to Review,
32
34
  - **AI scores before AI codes** — every ticket gets an ease/confidence rating and implementation spec before a single line is written. You always decide go/no-go.
33
35
  - **Your codebase, your rules** — Claude reads your project's `CLAUDE.md` and follows your conventions, patterns, and constraints.
34
36
  - **Build validation** — code must pass your build command before anything pushes. No broken PRs.
37
+ - **Blocked file guardrails** — configure glob patterns for files the agent must never touch (e.g. migrations, DB schemas). Enforced via prompt injection into both review and execute agents + hard post-diff validation.
35
38
  - **Full audit trail** — cost, duration, scores, branch name, PR link, and agent comments posted directly on the Notion ticket.
36
39
  - **Cost transparency** — every ticket shows exactly what it cost. Simple tasks run $0.35-0.55.
37
40
  - **Human-in-the-loop** — nothing merges without a developer reviewing the PR.
@@ -102,24 +105,22 @@ The rhythm is **you, AI, you, AI, AI, you** — three human touchpoints, three A
102
105
  ## Quick Start
103
106
 
104
107
  ```bash
105
- git clone https://github.com/JohnRiceML/ticket-to-pr.git
106
- cd ticket-to-pr
107
- npm install
108
+ npm install -g ticket-to-pr
108
109
 
109
110
  # Guided setup — configures Notion, projects, and .env.local
110
- npx tsx index.ts init
111
+ ticket-to-pr init
111
112
 
112
113
  # Verify everything is working
113
- npx tsx index.ts doctor
114
+ ticket-to-pr doctor
114
115
 
115
116
  # Test connection
116
- npx tsx index.ts --dry-run --once
117
+ ticket-to-pr --dry-run --once
117
118
 
118
119
  # Run once (process all pending tickets)
119
- npx tsx index.ts --once
120
+ ticket-to-pr --once
120
121
 
121
122
  # Run continuously (polls every 30s)
122
- npx tsx index.ts
123
+ ticket-to-pr
123
124
  ```
124
125
 
125
126
  ## Prerequisites
@@ -186,7 +187,7 @@ https://www.notion.so/yourteam/abc123def456789...?v=...
186
187
  The guided setup configures everything interactively — Notion credentials, tools, models, and projects:
187
188
 
188
189
  ```bash
189
- npx tsx index.ts init
190
+ ticket-to-pr init
190
191
  ```
191
192
 
192
193
  Init validates as you go: invalid Notion tokens and database IDs are rejected immediately (you'll be re-prompted), and it warns about missing tools. If you re-run `init` later, it detects existing config and asks whether to update or start fresh.
@@ -194,10 +195,10 @@ Init validates as you go: invalid Notion tokens and database IDs are rejected im
194
195
  ### 4. Verify
195
196
 
196
197
  ```bash
197
- npx tsx index.ts doctor
198
+ ticket-to-pr doctor
198
199
  # Should show all checks passing, including database schema validation
199
200
 
200
- npx tsx index.ts --dry-run --once
201
+ ticket-to-pr --dry-run --once
201
202
  # Should connect to Notion and report "No tickets to process"
202
203
  ```
203
204
 
@@ -209,7 +210,7 @@ npx tsx index.ts --dry-run --once
209
210
 
210
211
  | Command / Flag | Behavior |
211
212
  |----------------|----------|
212
- | `init` | Guided setup — validates Notion credentials live, configures projects, writes `.env.local` and `projects.json`. Detects existing config on re-run. |
213
+ | `init` | Guided setup — validates Notion credentials live, auto-detects build commands, generates starter `CLAUDE.md`, configures projects, writes `.env.local` and `projects.json`. Detects existing config on re-run. |
213
214
  | `doctor` | Diagnostic check — verifies environment, Notion connectivity, database schema, tools, and projects |
214
215
  | *(none)* | Continuous polling every 30s |
215
216
  | `--once` | Poll once, wait for agents to finish, exit |
@@ -218,7 +219,7 @@ npx tsx index.ts --dry-run --once
218
219
 
219
220
  ### `init` — Guided Setup
220
221
 
221
- Run `npx tsx index.ts init` to configure TicketToPR interactively:
222
+ Run `ticket-to-pr init` to configure TicketToPR interactively:
222
223
 
223
224
  ```
224
225
  TicketToPR Setup
@@ -246,7 +247,15 @@ Step 4: Projects
246
247
  Project name: MyApp
247
248
  Directory: /Users/you/Projects/MyApp
248
249
  ✓ Git repo git@github.com:you/MyApp.git
249
- Build command (optional): npm run build
250
+ Build command (npm run build): auto-detected from package.json
251
+ Base branch (main):
252
+ Glob patterns the agent must never touch (e.g. **/migrations/**, prisma/schema.prisma, **/*.sql)
253
+ Blocked file patterns (optional, comma-separated):
254
+ Skip automatic PR creation? (N):
255
+ Detected: TypeScript, Next.js, Tailwind CSS ← auto-detected from project files
256
+ Generate starter CLAUDE.md? (Y):
257
+ ✓ Generated CLAUDE.md /Users/you/Projects/MyApp/CLAUDE.md
258
+ Edit it to add project-specific rules and conventions.
250
259
 
251
260
  Add another project? (N):
252
261
 
@@ -255,18 +264,20 @@ Step 5: Save
255
264
  ✓ Updated projects.json
256
265
 
257
266
  Ready!
258
- Test: npx tsx index.ts doctor
267
+ Test: ticket-to-pr doctor
259
268
  Docs: https://www.tickettopr.com
260
269
  ```
261
270
 
262
271
  - **Blocks on bad config** — invalid Notion tokens and database IDs are rejected and re-prompted (won't save broken credentials)
272
+ - **Auto-detects build command** — reads `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, or `Makefile` and pre-fills the build command. Press Enter to accept or override.
273
+ - **Generates starter CLAUDE.md** — detects your project stack (language, framework, test runner, CSS, ORM) and offers to generate a `CLAUDE.md` with build commands, code style, and file structure. Both agents read this file for context.
263
274
  - **Re-run safe** — detects existing `.env.local` and `projects.json`, asks "update" or "start fresh"
264
275
  - **Free tier guard** — warns if you configure multiple projects without a Pro license
265
276
  - Masks existing secrets when showing defaults
266
277
 
267
278
  ### `doctor` — Diagnostic Check
268
279
 
269
- Run `npx tsx index.ts doctor` to verify your setup. It checks everything non-interactively:
280
+ Run `ticket-to-pr doctor` to verify your setup. It checks everything non-interactively:
270
281
 
271
282
  ```
272
283
  TicketToPR Doctor
@@ -296,8 +307,10 @@ Tools:
296
307
 
297
308
  Projects:
298
309
  ✓ MyApp /Users/you/Projects/MyApp
310
+ ○ Base branch main (auto-detected)
311
+ ○ Blocked files none configured
299
312
 
300
- Summary: 14 passed, 1 warnings, 0 failed
313
+ Summary: 14 passed, 3 warnings, 0 failed
301
314
  Docs: https://www.tickettopr.com
302
315
  ```
303
316
 
@@ -314,9 +327,9 @@ Docs: https://www.tickettopr.com
314
327
  3. **Project**: Your project name from `projects.json`
315
328
  4. **Description**: `Create a simple GET endpoint at /api/test/hello that returns { message: "hello world" }`
316
329
  5. Drag to **Review** column
317
- 6. Run `npx tsx index.ts --once` and watch it score the ticket
330
+ 6. Run `ticket-to-pr --once` and watch it score the ticket
318
331
  7. Check Notion — ticket should be in **Scored** with Ease, Confidence, Spec, Impact filled in
319
- 8. Drag to **Execute**, run `npx tsx index.ts --once` again
332
+ 8. Drag to **Execute**, run `ticket-to-pr --once` again
320
333
  9. Check Notion — ticket should be in **PR Ready** with Branch, Cost, and PR link
321
334
 
322
335
  Typical cost for this test: **~$0.49** ($0.22 review + $0.27 execute).
@@ -335,12 +348,10 @@ cat > ~/Library/LaunchAgents/com.ticket-to-pr.plist << 'EOF'
335
348
  <string>com.ticket-to-pr</string>
336
349
  <key>ProgramArguments</key>
337
350
  <array>
338
- <string>npx</string>
339
- <string>tsx</string>
340
- <string>index.ts</string>
351
+ <string>ticket-to-pr</string>
341
352
  </array>
342
353
  <key>WorkingDirectory</key>
343
- <string>/Users/YOUR_USERNAME/Projects/ticket-to-pr</string>
354
+ <string>/Users/YOUR_USERNAME</string>
344
355
  <key>RunAtLoad</key>
345
356
  <true/>
346
357
  <key>KeepAlive</key>
@@ -381,7 +392,7 @@ tail -f ~/Projects/ticket-to-pr/bridge.log
381
392
  The review agent explores your codebase without modifying anything:
382
393
 
383
394
  - **Tools**: Read, Glob, Grep, Task
384
- - **Context**: Reads your project's `CLAUDE.md` for architecture rules
395
+ - **Context**: Reads your project's `CLAUDE.md` for architecture rules. If `blockedFiles` are configured, the review agent factors those constraints into scoring.
385
396
  - **Output**: Ease score, confidence score, implementation spec, impact report, affected files, risks
386
397
  - **Budget**: $2.00 max, 25 turns max
387
398
  - **Typical cost**: $0.15 - $0.50
@@ -414,14 +425,16 @@ The execute agent implements the code based on the spec:
414
425
 
415
426
  ### Git Workflow
416
427
 
417
- 1. TicketToPR creates branch `notion/{8-char-id}/{ticket-slug}` from the default branch (auto-detected `main`, `master`, etc.)
418
- 2. Claude implements changes and makes atomic commits
419
- 3. TicketToPR runs your build command (if configured)
420
- 4. Build passes: pushes branch to origin
421
- 5. Creates a GitHub PR via `gh pr create` targeting the default branch (includes spec, impact, Notion link, cost)
422
- 6. PR URL written back to the Notion ticket
423
- 7. Ticket moves to **PR Ready**
424
- 8. Build fails: branch kept locally, ticket moves to **Failed**
428
+ 1. TicketToPR **fetches the latest** from `origin/<baseBranch>` (configurable per project, auto-detected by default)
429
+ 2. Creates branch `notion/{8-char-id}/{ticket-slug}` based on the fresh remote state
430
+ 3. Claude implements changes and makes atomic commits
431
+ 4. TicketToPR runs your build command (if configured)
432
+ 5. If `blockedFiles` patterns are configured, validates no off-limits files were touched
433
+ 6. Build passes + no blocked file violations: pushes branch to origin
434
+ 7. Creates a GitHub PR via `gh pr create` targeting the base branch (unless `skipPR` is enabled)
435
+ 8. PR URL written back to the Notion ticket
436
+ 9. Ticket moves to **PR Ready**
437
+ 10. Build fails or blocked file violation: no code is pushed, ticket moves to **Failed**
425
438
 
426
439
  ## Costs
427
440
 
@@ -480,6 +493,9 @@ Project configuration in `projects.json`:
480
493
  |-------|---------|
481
494
  | `projects.<name>.directory` | Absolute path to the project's local git repo |
482
495
  | `projects.<name>.buildCommand` | Optional build validation command (e.g. `npm run build`) |
496
+ | `projects.<name>.baseBranch` | Optional base branch (e.g. `develop`). Falls back to auto-detected default (`main`/`master`). |
497
+ | `projects.<name>.blockedFiles` | Optional array of glob patterns the agent must never touch (e.g. `["**/migrations/**", "**/*.sql"]`) |
498
+ | `projects.<name>.skipPR` | Optional boolean. Set `true` to push the branch but skip automatic PR creation. |
483
499
 
484
500
  ## Project Structure
485
501
 
@@ -505,24 +521,30 @@ ticket-to-pr/
505
521
 
506
522
  ## Adding a New Project
507
523
 
508
- Add to `projects.json` (or re-run `npx tsx index.ts init`):
524
+ Add to `projects.json` (or re-run `ticket-to-pr init`):
509
525
 
510
526
  ```json
511
527
  {
512
528
  "projects": {
513
529
  "MyProject": {
514
530
  "directory": "/absolute/path/to/project",
515
- "buildCommand": "npm run build"
531
+ "buildCommand": "npm run build",
532
+ "baseBranch": "develop",
533
+ "blockedFiles": ["**/migrations/**", "prisma/schema.prisma", "**/*.sql"],
534
+ "skipPR": false
516
535
  }
517
536
  }
518
537
  }
519
538
  ```
520
539
 
521
- 1. `buildCommand` is optional — omit it if you don't need build validation
522
- 2. The directory must be a git repo with an `origin` remote
523
- 3. If the project has a `CLAUDE.md`, both agents will read it for context
524
- 4. Create Notion tickets with `Project` set to the exact key name (case-sensitive)
525
- 5. Run `npx tsx index.ts doctor` to verify it will check the schema and project match
540
+ 1. All fields except `directory` are optional — omit any you don't need
541
+ 2. `baseBranch` which branch to base feature branches on. Auto-detected (`main`/`master`) if omitted.
542
+ 3. `blockedFiles` — glob patterns the agent must never touch. Enforced via prompt injection into both review and execute agents, plus a hard post-diff validation before push.
543
+ 4. `skipPR` set `true` to push branches without creating a PR (useful for repos that use a different PR workflow)
544
+ 5. The directory must be a git repo with an `origin` remote
545
+ 6. If the project has a `CLAUDE.md`, both agents will read it for context
546
+ 7. Create Notion tickets with `Project` set to the exact key name (case-sensitive)
547
+ 8. Run `ticket-to-pr doctor` to verify — it shows base branch, blocked files, and skip PR status per project
526
548
 
527
549
  ## Error Handling
528
550
 
@@ -533,6 +555,7 @@ Add to `projects.json` (or re-run `npx tsx index.ts init`):
533
555
  | Review agent fails | Ticket -> Failed, error written to Impact field with actionable detail |
534
556
  | Execute agent fails | Worktree cleaned up, ticket -> Failed |
535
557
  | Build validation fails | Ticket -> Failed with command, directory, and build output (up to 500 chars) |
558
+ | Blocked file violation | Ticket -> Failed with list of matched files and patterns. No code is pushed. |
536
559
  | Push fails | Ticket -> Failed, branch remains local |
537
560
  | PR creation fails | Ticket still moves to PR Ready (best-effort) |
538
561
  | Duplicate poll trigger | Skipped via in-memory lock per ticket ID |
@@ -612,6 +635,7 @@ Add to `projects.json` (or re-run `npx tsx index.ts init`):
612
635
  - **Read-only review** — the review agent cannot modify files. It only reads and analyzes.
613
636
  - **Sandboxed execution** — the execute agent has no access to the web, cannot push code, and cannot run destructive commands. TicketToPR handles git operations separately.
614
637
  - **Build gate** — code must pass your build validation before anything is pushed.
638
+ - **Blocked file gate** — if `blockedFiles` patterns are configured, they're injected into both the review and execute agent prompts. A hard post-diff check also runs before push. Any violations abort the run — no code reaches origin.
615
639
  - **Human gate** — pull requests require your review and approval before merging.
616
640
 
617
641
  ## Tech Stack
@@ -626,6 +650,8 @@ Add to `projects.json` (or re-run `npx tsx index.ts init`):
626
650
 
627
651
  Contributions are welcome! Please open an issue first to discuss what you'd like to change.
628
652
 
653
+ > **End users** should install via `npm install -g ticket-to-pr`. The instructions below are for contributors.
654
+
629
655
  ```bash
630
656
  git clone https://github.com/JohnRiceML/ticket-to-pr.git
631
657
  cd ticket-to-pr
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execSync } from 'node:child_process';
3
- import { readFileSync, existsSync } from 'node:fs';
3
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { mask, shellEscape, writeEnvFile, updateProjectsFile } from './lib/utils.js';
6
- import { getProjectNames, getProjectDir } from './lib/projects.js';
5
+ import { mask, shellEscape, writeEnvFile, updateProjectsFile, getDefaultBranch } from './lib/utils.js';
6
+ import { getProjectNames, getProjectDir, getBaseBranch, getBlockedFiles, getSkipPR } from './lib/projects.js';
7
7
  import { CONFIG_DIR } from './lib/paths.js';
8
8
  // -- Colors --
9
9
  const RESET = '\x1b[0m';
@@ -27,6 +27,209 @@ function checkCommand(cmd) {
27
27
  return { ok: false, output: '' };
28
28
  }
29
29
  }
30
+ function detectBuildCommand(dir) {
31
+ // Node.js — check package.json for build/test scripts
32
+ const pkgPath = join(dir, 'package.json');
33
+ if (existsSync(pkgPath)) {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
36
+ if (pkg.scripts?.build)
37
+ return 'npm run build';
38
+ if (pkg.scripts?.test)
39
+ return 'npm test';
40
+ }
41
+ catch { /* ignore parse errors */ }
42
+ }
43
+ // Rust
44
+ if (existsSync(join(dir, 'Cargo.toml')))
45
+ return 'cargo build';
46
+ // Go
47
+ if (existsSync(join(dir, 'go.mod')))
48
+ return 'go build ./...';
49
+ // Python
50
+ if (existsSync(join(dir, 'pyproject.toml')))
51
+ return 'python -m pytest';
52
+ // Makefile
53
+ if (existsSync(join(dir, 'Makefile')))
54
+ return 'make';
55
+ return undefined;
56
+ }
57
+ function detectProjectStack(dir) {
58
+ const stack = { language: 'JavaScript' };
59
+ // TypeScript detection
60
+ if (existsSync(join(dir, 'tsconfig.json'))) {
61
+ stack.language = 'TypeScript';
62
+ }
63
+ // Rust / Go / Python detection (override language)
64
+ if (existsSync(join(dir, 'Cargo.toml'))) {
65
+ stack.language = 'Rust';
66
+ stack.buildTool = 'cargo';
67
+ stack.testRunner = 'cargo test';
68
+ return stack;
69
+ }
70
+ if (existsSync(join(dir, 'go.mod'))) {
71
+ stack.language = 'Go';
72
+ stack.buildTool = 'go';
73
+ stack.testRunner = 'go test';
74
+ return stack;
75
+ }
76
+ if (existsSync(join(dir, 'pyproject.toml'))) {
77
+ stack.language = 'Python';
78
+ stack.testRunner = 'pytest';
79
+ return stack;
80
+ }
81
+ // Node.js ecosystem — read package.json for framework/tools
82
+ const pkgPath = join(dir, 'package.json');
83
+ if (existsSync(pkgPath)) {
84
+ try {
85
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
86
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
87
+ // Framework
88
+ if (allDeps['next'])
89
+ stack.framework = 'Next.js';
90
+ else if (allDeps['nuxt'])
91
+ stack.framework = 'Nuxt';
92
+ else if (allDeps['@remix-run/node'] || allDeps['@remix-run/react'])
93
+ stack.framework = 'Remix';
94
+ else if (allDeps['express'])
95
+ stack.framework = 'Express';
96
+ else if (allDeps['fastify'])
97
+ stack.framework = 'Fastify';
98
+ else if (allDeps['react'])
99
+ stack.framework = 'React';
100
+ else if (allDeps['vue'])
101
+ stack.framework = 'Vue';
102
+ else if (allDeps['svelte'])
103
+ stack.framework = 'Svelte';
104
+ // Test runner
105
+ if (allDeps['vitest'])
106
+ stack.testRunner = 'vitest';
107
+ else if (allDeps['jest'])
108
+ stack.testRunner = 'jest';
109
+ else if (allDeps['mocha'])
110
+ stack.testRunner = 'mocha';
111
+ // Build tool
112
+ if (allDeps['vite'] && !stack.framework?.includes('Next'))
113
+ stack.buildTool = 'vite';
114
+ else if (allDeps['webpack'])
115
+ stack.buildTool = 'webpack';
116
+ else if (allDeps['esbuild'])
117
+ stack.buildTool = 'esbuild';
118
+ else if (stack.language === 'TypeScript')
119
+ stack.buildTool = 'tsc';
120
+ // CSS
121
+ if (allDeps['tailwindcss'])
122
+ stack.css = 'Tailwind CSS';
123
+ // ORM
124
+ if (allDeps['prisma'] || allDeps['@prisma/client'])
125
+ stack.orm = 'Prisma';
126
+ else if (allDeps['drizzle-orm'])
127
+ stack.orm = 'Drizzle';
128
+ }
129
+ catch { /* ignore parse errors */ }
130
+ }
131
+ // Package manager
132
+ if (existsSync(join(dir, 'bun.lockb')))
133
+ stack.packageManager = 'bun';
134
+ else if (existsSync(join(dir, 'pnpm-lock.yaml')))
135
+ stack.packageManager = 'pnpm';
136
+ else if (existsSync(join(dir, 'yarn.lock')))
137
+ stack.packageManager = 'yarn';
138
+ else if (existsSync(join(dir, 'package-lock.json')))
139
+ stack.packageManager = 'npm';
140
+ return stack;
141
+ }
142
+ function generateClaudeMd(name, stack, buildCmd) {
143
+ const parts = [];
144
+ // Header
145
+ parts.push(`# ${name}\n`);
146
+ // Project overview
147
+ const stackDesc = [stack.language, stack.framework, stack.css, stack.orm].filter(Boolean).join(', ');
148
+ parts.push(`## Project overview`);
149
+ parts.push(`${stackDesc || stack.language} project.\n`);
150
+ // Build & test
151
+ parts.push(`## Build & test`);
152
+ if (buildCmd)
153
+ parts.push(`- Build: \`${buildCmd}\``);
154
+ if (stack.testRunner) {
155
+ const testCmd = stack.testRunner === 'vitest' ? 'npx vitest run'
156
+ : stack.testRunner === 'jest' ? 'npx jest'
157
+ : stack.testRunner === 'pytest' ? 'python -m pytest'
158
+ : stack.testRunner === 'cargo test' ? 'cargo test'
159
+ : stack.testRunner === 'go test' ? 'go test ./...'
160
+ : stack.testRunner;
161
+ parts.push(`- Test: \`${testCmd}\``);
162
+ }
163
+ const pm = stack.packageManager || 'npm';
164
+ if (['TypeScript', 'JavaScript'].includes(stack.language)) {
165
+ parts.push(`- Lint: \`${pm === 'npm' ? 'npm run' : pm} lint\``);
166
+ }
167
+ if (stack.language === 'TypeScript') {
168
+ parts.push(`- Type check: \`npx tsc --noEmit\``);
169
+ }
170
+ parts.push('');
171
+ // Code style
172
+ parts.push(`## Code style`);
173
+ if (stack.framework === 'Next.js') {
174
+ parts.push(`- Use functional components with ${stack.language}`);
175
+ parts.push(`- Prefer server components; add "use client" only when needed`);
176
+ }
177
+ else if (stack.framework === 'React') {
178
+ parts.push(`- Use functional components with hooks`);
179
+ }
180
+ else if (stack.language === 'Rust') {
181
+ parts.push(`- Follow standard Rust conventions (rustfmt, clippy)`);
182
+ }
183
+ else if (stack.language === 'Go') {
184
+ parts.push(`- Follow standard Go conventions (gofmt, go vet)`);
185
+ }
186
+ else if (stack.language === 'Python') {
187
+ parts.push(`- Follow PEP 8 conventions`);
188
+ }
189
+ if (stack.css === 'Tailwind CSS') {
190
+ parts.push(`- Use Tailwind CSS utility classes for styling`);
191
+ }
192
+ if (stack.orm === 'Prisma') {
193
+ parts.push(`- Use Prisma for database access`);
194
+ }
195
+ else if (stack.orm === 'Drizzle') {
196
+ parts.push(`- Use Drizzle ORM for database access`);
197
+ }
198
+ parts.push('');
199
+ // File structure
200
+ parts.push(`## File structure`);
201
+ if (stack.framework === 'Next.js') {
202
+ parts.push(`- app/ — routes and layouts`);
203
+ parts.push(`- components/ — React components`);
204
+ parts.push(`- lib/ — utilities and shared logic`);
205
+ if (stack.orm === 'Prisma')
206
+ parts.push(`- prisma/ — database schema`);
207
+ }
208
+ else if (stack.framework === 'Express' || stack.framework === 'Fastify') {
209
+ parts.push(`- src/ — application source`);
210
+ parts.push(`- routes/ — API routes`);
211
+ parts.push(`- lib/ — utilities and shared logic`);
212
+ }
213
+ else if (stack.language === 'Rust') {
214
+ parts.push(`- src/ — application source`);
215
+ parts.push(`- tests/ — integration tests`);
216
+ }
217
+ else if (stack.language === 'Go') {
218
+ parts.push(`- cmd/ — entrypoints`);
219
+ parts.push(`- internal/ — private packages`);
220
+ parts.push(`- pkg/ — public packages`);
221
+ }
222
+ else if (stack.language === 'Python') {
223
+ parts.push(`- src/ — application source`);
224
+ parts.push(`- tests/ — test files`);
225
+ }
226
+ else {
227
+ parts.push(`- src/ — application source`);
228
+ parts.push(`- lib/ — utilities and shared logic`);
229
+ }
230
+ parts.push('');
231
+ return parts.join('\n');
232
+ }
30
233
  function ask(rl, question, opts) {
31
234
  return new Promise((resolve) => {
32
235
  const suffix = opts?.defaultValue ? ` ${DIM}(${opts.defaultValue})${RESET}` : '';
@@ -275,6 +478,19 @@ export async function runDoctor() {
275
478
  }
276
479
  printStatus(true, `${name}`, `${dir}`);
277
480
  track(true);
481
+ // Display guardrail config
482
+ const configuredBase = getBaseBranch(name);
483
+ const detectedBase = getDefaultBranch(dir);
484
+ const baseDisplay = configuredBase
485
+ ? `${configuredBase}${configuredBase !== detectedBase ? ` (auto-detected: ${detectedBase})` : ''}`
486
+ : `${detectedBase} (auto-detected)`;
487
+ printStatus(null, ` Base branch`, baseDisplay);
488
+ const blocked = getBlockedFiles(name);
489
+ printStatus(null, ` Blocked files`, blocked.length > 0 ? blocked.join(', ') : 'none configured');
490
+ const skip = getSkipPR(name);
491
+ if (skip) {
492
+ printStatus(null, ` Skip PR`, 'enabled');
493
+ }
278
494
  }
279
495
  }
280
496
  // Summary
@@ -463,8 +679,39 @@ export async function runInit() {
463
679
  else {
464
680
  printStatus(null, 'Not a git repo', `${dir} — you can init git later`);
465
681
  }
466
- const buildCmd = await ask(rl, 'Build command (optional)');
467
- projects.push({ name, dir, buildCmd: buildCmd || undefined });
682
+ const detectedBuild = detectBuildCommand(dir);
683
+ const buildCmd = await ask(rl, 'Build command' + (detectedBuild ? '' : ' (optional)'), {
684
+ defaultValue: detectedBuild,
685
+ });
686
+ // Detect default branch for this project
687
+ const gitExists2 = existsSync(join(dir, '.git'));
688
+ const detectedBranch = gitExists2 ? getDefaultBranch(dir) : 'main';
689
+ const baseBranchInput = await ask(rl, 'Base branch', { defaultValue: detectedBranch });
690
+ const baseBranch = baseBranchInput !== detectedBranch ? baseBranchInput : undefined;
691
+ console.log(` ${DIM}Glob patterns the agent must never touch (e.g. **/migrations/**, prisma/schema.prisma, **/*.sql)${RESET}`);
692
+ const blockedInput = await ask(rl, 'Blocked file patterns (optional, comma-separated)');
693
+ const blockedFiles = blockedInput
694
+ ? blockedInput.split(',').map((s) => s.trim()).filter(Boolean)
695
+ : undefined;
696
+ const skipPRInput = await ask(rl, 'Skip automatic PR creation?', { defaultValue: 'N' });
697
+ const skipPR = skipPRInput.toLowerCase() === 'y' || skipPRInput.toLowerCase() === 'yes' ? true : undefined;
698
+ projects.push({ name, dir, buildCmd: buildCmd || undefined, baseBranch, blockedFiles, skipPR });
699
+ // Offer to generate CLAUDE.md if it doesn't exist
700
+ const claudeMdPath = join(dir, 'CLAUDE.md');
701
+ if (!existsSync(claudeMdPath)) {
702
+ const stack = detectProjectStack(dir);
703
+ console.log(` ${DIM}Detected: ${[stack.language, stack.framework, stack.css, stack.orm].filter(Boolean).join(', ')}${RESET}`);
704
+ const genClaudeMd = await ask(rl, 'Generate starter CLAUDE.md?', { defaultValue: 'Y' });
705
+ if (genClaudeMd.toLowerCase() === 'y' || genClaudeMd.toLowerCase() === 'yes') {
706
+ const content = generateClaudeMd(name, stack, buildCmd || undefined);
707
+ writeFileSync(claudeMdPath, content, 'utf-8');
708
+ printStatus(true, 'Generated CLAUDE.md', claudeMdPath);
709
+ console.log(` ${DIM}Edit it to add project-specific rules and conventions.${RESET}`);
710
+ }
711
+ }
712
+ else {
713
+ printStatus(true, 'CLAUDE.md exists', claudeMdPath);
714
+ }
468
715
  console.log('');
469
716
  const another = await ask(rl, 'Add another project?', { defaultValue: 'N' });
470
717
  addMore = another.toLowerCase() === 'y' || another.toLowerCase() === 'yes';
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import { execSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { query } from '@anthropic-ai/claude-agent-sdk';
5
5
  import { CONFIG, REVIEW_OUTPUT_SCHEMA, isPro } from './config.js';
6
- import { sleep, clamp, extractJsonFromOutput, shellEscape, extractNumber, loadEnv, createWorktree, removeWorktree, getDefaultBranch } from './lib/utils.js';
7
- import { getProjectDir, getProjectNames, getBuildCommand } from './lib/projects.js';
6
+ import { sleep, clamp, extractJsonFromOutput, shellEscape, extractNumber, loadEnv, createWorktree, removeWorktree, getDefaultBranch, validateNoBlockedFiles } from './lib/utils.js';
7
+ import { getProjectDir, getProjectNames, getBuildCommand, getBaseBranch, getBlockedFiles, getSkipPR } from './lib/projects.js';
8
8
  import { fetchTicketsByStatus, fetchTicketDetails, writeReviewResults, writeExecutionResults, moveTicketStatus, writeFailure, addComment, } from './lib/notion.js';
9
9
  import { PACKAGE_ROOT, CONFIG_DIR } from './lib/paths.js';
10
10
  // Load .env.local from the user's working directory
@@ -51,7 +51,8 @@ async function runReviewAgent(ticket) {
51
51
  }
52
52
  log(CYAN, 'REVIEW', `Starting review for "${ticket.title}" in ${ticket.project}`);
53
53
  const startTime = Date.now();
54
- const prompt = [
54
+ const blockedFiles = getBlockedFiles(ticket.project);
55
+ const promptParts = [
55
56
  reviewPrompt,
56
57
  '',
57
58
  '## Ticket',
@@ -62,7 +63,11 @@ async function runReviewAgent(ticket) {
62
63
  '',
63
64
  '**Page Content**:',
64
65
  ticket.bodyBlocks,
65
- ].join('\n');
66
+ ];
67
+ if (blockedFiles.length > 0) {
68
+ promptParts.push('', '## BLOCKED FILES — CANNOT BE MODIFIED', 'The following file patterns are off-limits. Factor this into your scoring — if the natural implementation would touch these files, lower the ease and confidence scores and note it in risks.', '', ...blockedFiles.map(p => `- \`${p}\``));
69
+ }
70
+ const prompt = promptParts.join('\n');
66
71
  const messages = query({
67
72
  prompt,
68
73
  options: {
@@ -157,16 +162,20 @@ async function runExecuteAgent(ticket) {
157
162
  .slice(0, 40);
158
163
  const branchName = `notion/${shortId}/${slug}`;
159
164
  const worktreeDir = join(projectDir, '.worktrees', branchName.replace(/\//g, '_'));
165
+ // Resolve per-project guardrails
166
+ const baseBranch = getBaseBranch(ticket.project) || getDefaultBranch(projectDir);
167
+ const blockedFiles = getBlockedFiles(ticket.project);
168
+ const skipPR = getSkipPR(ticket.project);
160
169
  log(MAGENTA, 'EXECUTE', `Starting execution for "${ticket.title}" on branch ${branchName}`);
161
170
  const startTime = Date.now();
162
171
  // Move to In Progress immediately
163
172
  await moveTicketStatus(ticket.id, CONFIG.COLUMNS.IN_PROGRESS);
164
- // Git: create isolated worktree
165
- createWorktree(projectDir, branchName, worktreeDir);
173
+ // Git: create isolated worktree (fetches origin/<baseBranch> first)
174
+ createWorktree(projectDir, branchName, worktreeDir, baseBranch);
166
175
  let cost = 0;
167
176
  let commitCount = 0;
168
177
  try {
169
- const prompt = [
178
+ const promptParts = [
170
179
  executePrompt,
171
180
  '',
172
181
  '## Ticket',
@@ -183,7 +192,11 @@ async function runExecuteAgent(ticket) {
183
192
  '',
184
193
  '**Page Content**:',
185
194
  ticket.bodyBlocks,
186
- ].join('\n');
195
+ ];
196
+ if (blockedFiles.length > 0) {
197
+ promptParts.push('', '## BLOCKED FILES — DO NOT TOUCH', 'The following file patterns are off-limits. Do NOT create, modify, or delete any files matching these patterns. Violations will cause the entire run to fail.', '', ...blockedFiles.map((p) => `- \`${p}\``));
198
+ }
199
+ const prompt = promptParts.join('\n');
187
200
  const messages = query({
188
201
  prompt,
189
202
  options: {
@@ -216,7 +229,6 @@ async function runExecuteAgent(ticket) {
216
229
  }
217
230
  }
218
231
  // Count commits made
219
- const baseBranch = getDefaultBranch(projectDir);
220
232
  try {
221
233
  const commitLog = execSync(`git log ${shellEscape(baseBranch)}..${shellEscape(branchName)} --oneline`, { cwd: worktreeDir, stdio: 'pipe' });
222
234
  commitCount = commitLog.toString().trim().split('\n').filter(Boolean).length;
@@ -246,36 +258,49 @@ async function runExecuteAgent(ticket) {
246
258
  throw new Error(`Build validation failed.\nCommand: ${buildCmd}\nDirectory: ${worktreeDir}\n${detail ? `Output:\n${detail}` : (e instanceof Error ? e.message : String(e))}`);
247
259
  }
248
260
  }
261
+ // Post-execution: validate no blocked files were touched
262
+ if (blockedFiles.length > 0) {
263
+ const violations = validateNoBlockedFiles(worktreeDir, baseBranch, blockedFiles);
264
+ if (violations.length > 0) {
265
+ throw new Error(`Blocked file violation — the agent modified files that are off-limits:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\nNo code was pushed. Fix the blocked file patterns in projects.json or adjust the ticket scope.`);
266
+ }
267
+ log(GREEN, 'VALIDATE', 'No blocked file violations');
268
+ }
249
269
  // Push branch
250
270
  log(CYAN, 'PUSH', `Pushing ${branchName}`);
251
271
  execSync(`git push -u origin ${shellEscape(branchName)}`, { cwd: worktreeDir, stdio: 'pipe' });
252
- // Create PR
272
+ // Create PR (unless skipPR is configured)
253
273
  let prUrl = '';
254
- try {
255
- log(CYAN, 'PR', 'Creating pull request...');
256
- const prBody = [
257
- '## Summary',
258
- '',
259
- ticket.spec ?? ticket.description,
260
- '',
261
- '## Impact',
262
- '',
263
- ticket.impact ?? '_No impact analysis_',
264
- '',
265
- `## Notion Ticket`,
266
- '',
267
- `[View in Notion](https://www.notion.so/${ticket.id.replace(/-/g, '')})`,
268
- '',
269
- '---',
270
- `Cost: $${cost.toFixed(2)} | Review: Ease ${extractNumber(ticket, 'ease')}/10, Confidence ${extractNumber(ticket, 'confidence')}/10`,
271
- ].join('\n');
272
- const prResult = execSync(`gh pr create --title ${shellEscape(ticket.title)} --body ${shellEscape(prBody)} --base ${shellEscape(baseBranch)} --head ${branchName}`, { cwd: worktreeDir, stdio: 'pipe', timeout: 30_000 });
273
- prUrl = prResult.toString().trim();
274
- log(GREEN, 'PR', `Created: ${prUrl}`);
274
+ if (skipPR) {
275
+ log(YELLOW, 'PR', 'Skipping PR creation (skipPR enabled for this project)');
275
276
  }
276
- catch (e) {
277
- // PR creation is best-effort — don't fail the ticket over it
278
- log(YELLOW, 'PR', `Failed to create PR: ${e instanceof Error ? e.message : e}`);
277
+ else {
278
+ try {
279
+ log(CYAN, 'PR', 'Creating pull request...');
280
+ const prBody = [
281
+ '## Summary',
282
+ '',
283
+ ticket.spec ?? ticket.description,
284
+ '',
285
+ '## Impact',
286
+ '',
287
+ ticket.impact ?? '_No impact analysis_',
288
+ '',
289
+ `## Notion Ticket`,
290
+ '',
291
+ `[View in Notion](https://www.notion.so/${ticket.id.replace(/-/g, '')})`,
292
+ '',
293
+ '---',
294
+ `Cost: $${cost.toFixed(2)} | Review: Ease ${extractNumber(ticket, 'ease')}/10, Confidence ${extractNumber(ticket, 'confidence')}/10`,
295
+ ].join('\n');
296
+ const prResult = execSync(`gh pr create --title ${shellEscape(ticket.title)} --body ${shellEscape(prBody)} --base ${shellEscape(baseBranch)} --head ${branchName}`, { cwd: worktreeDir, stdio: 'pipe', timeout: 30_000 });
297
+ prUrl = prResult.toString().trim();
298
+ log(GREEN, 'PR', `Created: ${prUrl}`);
299
+ }
300
+ catch (e) {
301
+ // PR creation is best-effort — don't fail the ticket over it
302
+ log(YELLOW, 'PR', `Failed to create PR: ${e instanceof Error ? e.message : e}`);
303
+ }
279
304
  }
280
305
  // Update Notion
281
306
  await writeExecutionResults(ticket.id, { branch: branchName, cost, prUrl });
@@ -1,6 +1,9 @@
1
1
  export declare function getProjectDir(name: string): string | undefined;
2
2
  export declare function getProjectNames(): string[];
3
3
  export declare function getBuildCommand(name: string): string | undefined;
4
+ export declare function getBaseBranch(name: string): string | undefined;
5
+ export declare function getBlockedFiles(name: string): string[];
6
+ export declare function getSkipPR(name: string): boolean;
4
7
  export declare function getAllProjects(): Record<string, string>;
5
8
  /** Reset the in-memory cache (for tests). */
6
9
  export declare function _resetCache(): void;
@@ -24,6 +24,15 @@ export function getProjectNames() {
24
24
  export function getBuildCommand(name) {
25
25
  return load().projects[name]?.buildCommand;
26
26
  }
27
+ export function getBaseBranch(name) {
28
+ return load().projects[name]?.baseBranch;
29
+ }
30
+ export function getBlockedFiles(name) {
31
+ return load().projects[name]?.blockedFiles ?? [];
32
+ }
33
+ export function getSkipPR(name) {
34
+ return load().projects[name]?.skipPR ?? false;
35
+ }
27
36
  export function getAllProjects() {
28
37
  const data = load();
29
38
  const result = {};
@@ -12,10 +12,14 @@ export declare function updateProjectsFile(filepath: string, projects: Array<{
12
12
  name: string;
13
13
  dir: string;
14
14
  buildCmd?: string;
15
+ baseBranch?: string;
16
+ blockedFiles?: string[];
17
+ skipPR?: boolean;
15
18
  }>): void;
16
19
  export declare function getDefaultBranch(projectDir: string): string;
17
20
  /** Reset the default branch cache (for tests). */
18
21
  export declare function _resetDefaultBranchCache(): void;
19
22
  export declare function ensureWorktreesIgnored(projectDir: string): void;
20
- export declare function createWorktree(projectDir: string, branchName: string, worktreeDir: string): void;
23
+ export declare function createWorktree(projectDir: string, branchName: string, worktreeDir: string, baseBranch?: string): void;
24
+ export declare function validateNoBlockedFiles(worktreeDir: string, baseBranch: string, blockedPatterns: string[]): string[];
21
25
  export declare function removeWorktree(projectDir: string, worktreeDir: string): void;
package/dist/lib/utils.js CHANGED
@@ -133,6 +133,9 @@ export function updateProjectsFile(filepath, projects) {
133
133
  data.projects[proj.name] = {
134
134
  directory: proj.dir,
135
135
  ...(proj.buildCmd ? { buildCommand: proj.buildCmd } : {}),
136
+ ...(proj.baseBranch ? { baseBranch: proj.baseBranch } : {}),
137
+ ...(proj.blockedFiles && proj.blockedFiles.length > 0 ? { blockedFiles: proj.blockedFiles } : {}),
138
+ ...(proj.skipPR ? { skipPR: proj.skipPR } : {}),
136
139
  };
137
140
  }
138
141
  writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n');
@@ -193,9 +196,19 @@ export function ensureWorktreesIgnored(projectDir) {
193
196
  // Best effort — don't block worktree creation over gitignore
194
197
  }
195
198
  }
196
- export function createWorktree(projectDir, branchName, worktreeDir) {
199
+ export function createWorktree(projectDir, branchName, worktreeDir, baseBranch) {
197
200
  mkdirSync(join(projectDir, '.worktrees'), { recursive: true });
198
201
  ensureWorktreesIgnored(projectDir);
202
+ // Fetch latest so new branch starts from up-to-date base
203
+ const base = baseBranch || 'main';
204
+ try {
205
+ execSync(`git fetch origin ${shellEscape(base)}`, {
206
+ cwd: projectDir, stdio: 'pipe', timeout: 30_000,
207
+ });
208
+ }
209
+ catch {
210
+ // Best effort — offline/no remote continues with local state
211
+ }
199
212
  // Clean up stale worktree if it exists from a crashed run
200
213
  if (existsSync(worktreeDir)) {
201
214
  try {
@@ -209,9 +222,9 @@ export function createWorktree(projectDir, branchName, worktreeDir) {
209
222
  execSync('git worktree prune', { cwd: projectDir, stdio: 'pipe' });
210
223
  }
211
224
  }
212
- // Try creating with a new branch first
225
+ // Try creating with a new branch based on origin/<baseBranch>
213
226
  try {
214
- execSync(`git worktree add ${shellEscape(worktreeDir)} -b ${shellEscape(branchName)}`, {
227
+ execSync(`git worktree add ${shellEscape(worktreeDir)} -b ${shellEscape(branchName)} origin/${shellEscape(base)}`, {
215
228
  cwd: projectDir,
216
229
  stdio: 'pipe',
217
230
  });
@@ -229,6 +242,73 @@ export function createWorktree(projectDir, branchName, worktreeDir) {
229
242
  }
230
243
  }
231
244
  }
245
+ // -- Blocked file validation --
246
+ /** Convert a simple glob pattern to a regex. Supports **, *, and ? wildcards. */
247
+ function globToRegex(pattern) {
248
+ let regex = '';
249
+ let i = 0;
250
+ while (i < pattern.length) {
251
+ const ch = pattern[i];
252
+ if (ch === '*' && pattern[i + 1] === '*') {
253
+ // ** matches any path segments
254
+ regex += '.*';
255
+ i += 2;
256
+ // skip trailing slash after **
257
+ if (pattern[i] === '/')
258
+ i++;
259
+ }
260
+ else if (ch === '*') {
261
+ // * matches anything except /
262
+ regex += '[^/]*';
263
+ i++;
264
+ }
265
+ else if (ch === '?') {
266
+ regex += '[^/]';
267
+ i++;
268
+ }
269
+ else if ('.+^${}()|[]\\'.includes(ch)) {
270
+ regex += '\\' + ch;
271
+ i++;
272
+ }
273
+ else {
274
+ regex += ch;
275
+ i++;
276
+ }
277
+ }
278
+ return new RegExp(`^${regex}$`);
279
+ }
280
+ export function validateNoBlockedFiles(worktreeDir, baseBranch, blockedPatterns) {
281
+ if (blockedPatterns.length === 0)
282
+ return [];
283
+ let changedFiles;
284
+ try {
285
+ const output = execSync(`git diff --name-only origin/${shellEscape(baseBranch)}...HEAD`, { cwd: worktreeDir, stdio: 'pipe' }).toString().trim();
286
+ changedFiles = output ? output.split('\n') : [];
287
+ }
288
+ catch {
289
+ // Fallback: diff against HEAD~1 or empty if no commits
290
+ try {
291
+ const output = execSync('git diff --name-only HEAD~1...HEAD', {
292
+ cwd: worktreeDir, stdio: 'pipe',
293
+ }).toString().trim();
294
+ changedFiles = output ? output.split('\n') : [];
295
+ }
296
+ catch {
297
+ return []; // No commits to validate
298
+ }
299
+ }
300
+ const regexes = blockedPatterns.map(globToRegex);
301
+ const violations = [];
302
+ for (const file of changedFiles) {
303
+ for (let i = 0; i < regexes.length; i++) {
304
+ if (regexes[i].test(file)) {
305
+ violations.push(`${file} (matched pattern: ${blockedPatterns[i]})`);
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ return violations;
311
+ }
232
312
  export function removeWorktree(projectDir, worktreeDir) {
233
313
  try {
234
314
  execSync(`git worktree remove ${shellEscape(worktreeDir)} --force`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticket-to-pr",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Drag a Notion ticket, get a pull request. AI-powered dev automation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,10 @@
2
2
  "projects": {
3
3
  "MyProject": {
4
4
  "directory": "/absolute/path/to/project",
5
- "buildCommand": "npm run build"
5
+ "buildCommand": "npm run build",
6
+ "baseBranch": "develop",
7
+ "blockedFiles": ["**/migrations/**", "prisma/schema.prisma", "**/*.sql"],
8
+ "skipPR": false
6
9
  }
7
10
  }
8
11
  }
@@ -14,6 +14,7 @@ You have been given a ticket with an implementation spec. Follow the spec and im
14
14
  8. If the spec is unclear, implement the most conservative interpretation.
15
15
  9. Run existing tests if available, but do not add new test files unless the spec explicitly requires it.
16
16
  10. Do not modify files outside the scope of the spec.
17
+ 11. If your prompt includes a "BLOCKED FILES" section, you MUST NOT modify any files matching those patterns. Violations will cause the entire run to fail.
17
18
 
18
19
  ## When Done
19
20
  Commit all changes with a final commit message summarizing what was done. The commit message should reference the ticket title.
package/prompts/review.md CHANGED
@@ -41,3 +41,4 @@ You MUST end your response with a JSON code block containing exactly these field
41
41
  - List EVERY file that will be touched in affectedFiles.
42
42
  - Read the project's CLAUDE.md if it exists for project-specific rules and architecture.
43
43
  - Explore relevant code files to understand existing patterns before scoring.
44
+ - If the prompt includes a "BLOCKED FILES" section, factor those constraints into your scoring. If the natural implementation would need to modify blocked files, lower the ease score and note the constraint in risks.