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 +65 -39
- package/dist/cli.js +252 -5
- package/dist/index.js +59 -34
- package/dist/lib/projects.d.ts +3 -0
- package/dist/lib/projects.js +9 -0
- package/dist/lib/utils.d.ts +5 -1
- package/dist/lib/utils.js +83 -3
- package/package.json +1 -1
- package/projects.example.json +4 -1
- package/prompts/execute.md +1 -0
- package/prompts/review.md +1 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# TicketToPR
|
|
4
4
|
|
|
5
|
+
[](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
|
-
|
|
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
|
-
|
|
111
|
+
ticket-to-pr init
|
|
111
112
|
|
|
112
113
|
# Verify everything is working
|
|
113
|
-
|
|
114
|
+
ticket-to-pr doctor
|
|
114
115
|
|
|
115
116
|
# Test connection
|
|
116
|
-
|
|
117
|
+
ticket-to-pr --dry-run --once
|
|
117
118
|
|
|
118
119
|
# Run once (process all pending tickets)
|
|
119
|
-
|
|
120
|
+
ticket-to-pr --once
|
|
120
121
|
|
|
121
122
|
# Run continuously (polls every 30s)
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
ticket-to-pr doctor
|
|
198
199
|
# Should show all checks passing, including database schema validation
|
|
199
200
|
|
|
200
|
-
|
|
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 `
|
|
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 (
|
|
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:
|
|
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 `
|
|
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,
|
|
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 `
|
|
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 `
|
|
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>
|
|
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
|
|
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
|
|
418
|
-
2.
|
|
419
|
-
3.
|
|
420
|
-
4.
|
|
421
|
-
5.
|
|
422
|
-
6.
|
|
423
|
-
7.
|
|
424
|
-
8.
|
|
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 `
|
|
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. `
|
|
522
|
-
2.
|
|
523
|
-
3.
|
|
524
|
-
4.
|
|
525
|
-
5.
|
|
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
|
|
467
|
-
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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
|
-
]
|
|
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
|
-
|
|
255
|
-
log(
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 });
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/projects.js
CHANGED
|
@@ -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 = {};
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -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
|
|
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
package/projects.example.json
CHANGED
|
@@ -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
|
}
|
package/prompts/execute.md
CHANGED
|
@@ -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.
|