ticket-to-pr 1.2.2 → 1.4.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 -11
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -1
- package/dist/config.d.ts +34 -1
- package/dist/config.js +17 -1
- package/dist/index.js +167 -5
- package/dist/lib/notion.js +6 -1
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +45 -2
- package/package.json +1 -1
- package/prompts/diff-review.md +28 -0
- package/prompts/execute.md +10 -0
- package/prompts/review.md +14 -1
package/README.md
CHANGED
|
@@ -212,6 +212,11 @@ ticket-to-pr --dry-run --once
|
|
|
212
212
|
|----------------|----------|
|
|
213
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. |
|
|
214
214
|
| `doctor` | Diagnostic check — verifies environment, Notion connectivity, database schema, tools, and projects |
|
|
215
|
+
| `model` | View current models and available options |
|
|
216
|
+
| `model <review\|execute\|both> <model>` | Set the Claude model for an agent. Accepts aliases (`opus`, `sonnet`, `haiku`) or full model IDs. |
|
|
217
|
+
| `learnings` | View accumulated project learnings from past agent runs |
|
|
218
|
+
| `learnings <project>` | View learnings for a specific project |
|
|
219
|
+
| `learnings clear <project>` | Clear a project's learnings file |
|
|
215
220
|
| *(none)* | Continuous polling every 30s |
|
|
216
221
|
| `--once` | Poll once, wait for agents to finish, exit |
|
|
217
222
|
| `--dry-run` | Poll and log what would happen, don't run agents |
|
|
@@ -320,6 +325,52 @@ Docs: https://www.tickettopr.com
|
|
|
320
325
|
- `gh` missing is a warning (PRs won't auto-create but everything else works), `claude` missing is a hard failure
|
|
321
326
|
- Exits with code 1 if any hard failures, 0 otherwise
|
|
322
327
|
|
|
328
|
+
### `model` — Change AI Models
|
|
329
|
+
|
|
330
|
+
View or change which Claude models the agents use:
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
# Show current models and available options
|
|
334
|
+
ticket-to-pr model
|
|
335
|
+
|
|
336
|
+
# Set review model (used for scoring tickets)
|
|
337
|
+
ticket-to-pr model review sonnet
|
|
338
|
+
|
|
339
|
+
# Set execute model (used for writing code)
|
|
340
|
+
ticket-to-pr model execute opus
|
|
341
|
+
|
|
342
|
+
# Set both at once
|
|
343
|
+
ticket-to-pr model both haiku
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Available model aliases:
|
|
347
|
+
|
|
348
|
+
| Alias | Model ID | Best for |
|
|
349
|
+
|-------|----------|----------|
|
|
350
|
+
| `opus` | `claude-opus-4-6` | Best quality (recommended for execute) |
|
|
351
|
+
| `sonnet` | `claude-sonnet-4-6` | Fast and capable (recommended for review) |
|
|
352
|
+
| `sonnet45` | `claude-sonnet-4-5-20250929` | Previous generation Sonnet |
|
|
353
|
+
| `haiku` | `claude-haiku-4-5-20251001` | Fastest, lowest cost |
|
|
354
|
+
|
|
355
|
+
You can also pass a full model ID directly (e.g. `ticket-to-pr model review claude-sonnet-4-5-20250929`). Changes are saved to `.env.local` and take effect on the next poll cycle.
|
|
356
|
+
|
|
357
|
+
### `learnings` — Project Memory
|
|
358
|
+
|
|
359
|
+
TicketToPR accumulates learnings from every agent run — successes, failures, patterns, and mistakes. These are automatically injected into future agent prompts so the AI gets smarter about your project over time.
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
# View all project learnings
|
|
363
|
+
ticket-to-pr learnings
|
|
364
|
+
|
|
365
|
+
# View learnings for a specific project
|
|
366
|
+
ticket-to-pr learnings MyProject
|
|
367
|
+
|
|
368
|
+
# Clear learnings for a project (start fresh)
|
|
369
|
+
ticket-to-pr learnings clear MyProject
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Learnings are stored in each project directory at `.ticket-to-pr/learnings.md` (auto-gitignored). Failed tickets are especially valuable — the agent learns what not to do next time.
|
|
373
|
+
|
|
323
374
|
### Your First Ticket
|
|
324
375
|
|
|
325
376
|
1. Click **"+ New"** on your Notion board
|
|
@@ -393,7 +444,7 @@ The review agent explores your codebase without modifying anything:
|
|
|
393
444
|
|
|
394
445
|
- **Tools**: Read, Glob, Grep, Task
|
|
395
446
|
- **Context**: Reads your project's `CLAUDE.md` for architecture rules. If `blockedFiles` are configured, the review agent factors those constraints into scoring.
|
|
396
|
-
- **Output**: Ease score, confidence score, implementation spec, impact report, affected files, risks
|
|
447
|
+
- **Output**: Ease score, confidence score, implementation spec, impact report, affected files, risks, **acceptance test cases**
|
|
397
448
|
- **Budget**: $2.00 max, 25 turns max
|
|
398
449
|
- **Typical cost**: $0.15 - $0.50
|
|
399
450
|
|
|
@@ -415,7 +466,7 @@ The review agent explores your codebase without modifying anything:
|
|
|
415
466
|
|
|
416
467
|
### Execute Agent (Write Access)
|
|
417
468
|
|
|
418
|
-
The execute agent implements the code based on the spec
|
|
469
|
+
The execute agent implements the code based on the spec. When the review agent generates acceptance tests, the execute agent follows a **test-first workflow** — writing test files before implementation code.
|
|
419
470
|
|
|
420
471
|
- **Tools**: Read, Glob, Grep, Edit, Write + limited Bash (git, build, test only)
|
|
421
472
|
- **Dev access** (opt-in): When `devAccess` is enabled, additionally allows `npx tsx`, `node`, `npm run`, `npx vitest`, `npx jest`, `npx prisma`, `python`, and `curl` to localhost/127.0.0.1 only
|
|
@@ -428,14 +479,15 @@ The execute agent implements the code based on the spec:
|
|
|
428
479
|
|
|
429
480
|
1. TicketToPR **fetches the latest** from `origin/<baseBranch>` (configurable per project, auto-detected by default)
|
|
430
481
|
2. Creates branch `notion/{8-char-id}/{ticket-slug}` based on the fresh remote state
|
|
431
|
-
3. Claude implements changes and makes atomic commits
|
|
432
|
-
4.
|
|
433
|
-
5.
|
|
434
|
-
6.
|
|
435
|
-
7.
|
|
436
|
-
8. PR
|
|
437
|
-
9.
|
|
438
|
-
10.
|
|
482
|
+
3. Claude implements changes and makes atomic commits (test-first if acceptance tests were generated)
|
|
483
|
+
4. **Diff review**: a lightweight Haiku agent reviews the diff against the spec — catches issues before push
|
|
484
|
+
5. TicketToPR runs your build command (if configured)
|
|
485
|
+
6. If `blockedFiles` patterns are configured, validates no off-limits files were touched
|
|
486
|
+
7. All checks pass: pushes branch to origin
|
|
487
|
+
8. Creates a GitHub PR via `gh pr create` targeting the base branch (unless `skipPR` is enabled)
|
|
488
|
+
9. PR URL written back to the Notion ticket
|
|
489
|
+
10. Ticket moves to **PR Ready**
|
|
490
|
+
11. Any check fails (diff review, build, blocked files): no code is pushed, ticket moves to **Failed**
|
|
439
491
|
|
|
440
492
|
## Costs
|
|
441
493
|
|
|
@@ -481,6 +533,8 @@ All settings in `config.ts`:
|
|
|
481
533
|
|
|
482
534
|
| Setting | Default | Purpose |
|
|
483
535
|
|---------|---------|---------|
|
|
536
|
+
| `REVIEW_MODEL` | `claude-sonnet-4-6` | Review agent model (change with `ticket-to-pr model review <model>`) |
|
|
537
|
+
| `EXECUTE_MODEL` | `claude-opus-4-6` | Execute agent model (change with `ticket-to-pr model execute <model>`) |
|
|
484
538
|
| `POLL_INTERVAL_MS` | 30000 | How often to check Notion (ms) |
|
|
485
539
|
| `REVIEW_BUDGET_USD` | 2.00 | Max USD per review agent run |
|
|
486
540
|
| `EXECUTE_BUDGET_USD` | 15.00 | Max USD per execute agent run |
|
|
@@ -505,7 +559,7 @@ Project configuration in `projects.json`:
|
|
|
505
559
|
```
|
|
506
560
|
ticket-to-pr/
|
|
507
561
|
index.ts # Poll loop, agent runner, worktree git workflow, graceful shutdown
|
|
508
|
-
cli.ts # init
|
|
562
|
+
cli.ts # init, doctor, and model commands
|
|
509
563
|
config.ts # Budgets, column names, license check, TypeScript types
|
|
510
564
|
projects.json # Your project directories and build commands (git-ignored, copy from example)
|
|
511
565
|
projects.example.json # Template for projects.json
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createInterface } from 'node:readline';
|
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { mask, shellEscape, writeEnvFile, updateProjectsFile, getDefaultBranch } from './lib/utils.js';
|
|
5
|
+
import { mask, shellEscape, writeEnvFile, updateProjectsFile, getDefaultBranch, parseEnvFile, readLearnings } from './lib/utils.js';
|
|
6
|
+
import { unlinkSync } from 'node:fs';
|
|
6
7
|
import { getProjectNames, getProjectDir, getBaseBranch, getBlockedFiles, getSkipPR } from './lib/projects.js';
|
|
7
8
|
import { CONFIG_DIR } from './lib/paths.js';
|
|
8
9
|
// -- Colors --
|
|
@@ -768,3 +769,159 @@ export async function runInit() {
|
|
|
768
769
|
rl.close();
|
|
769
770
|
}
|
|
770
771
|
}
|
|
772
|
+
// -- Model --
|
|
773
|
+
const KNOWN_MODELS = [
|
|
774
|
+
{ alias: 'opus', id: 'claude-opus-4-6', description: 'Best quality, highest cost' },
|
|
775
|
+
{ alias: 'sonnet', id: 'claude-sonnet-4-6', description: 'Fast and capable (recommended for review)' },
|
|
776
|
+
{ alias: 'sonnet45', id: 'claude-sonnet-4-5-20250929', description: 'Previous generation Sonnet' },
|
|
777
|
+
{ alias: 'haiku', id: 'claude-haiku-4-5-20251001', description: 'Fastest, lowest cost' },
|
|
778
|
+
];
|
|
779
|
+
function resolveModel(input) {
|
|
780
|
+
const lower = input.toLowerCase();
|
|
781
|
+
const byAlias = KNOWN_MODELS.find((m) => m.alias === lower);
|
|
782
|
+
if (byAlias)
|
|
783
|
+
return byAlias;
|
|
784
|
+
const byId = KNOWN_MODELS.find((m) => m.id === lower);
|
|
785
|
+
if (byId)
|
|
786
|
+
return byId;
|
|
787
|
+
// Accept any claude- prefixed model ID as a pass-through
|
|
788
|
+
if (input.startsWith('claude-'))
|
|
789
|
+
return { alias: input, id: input };
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
export async function runModel(args) {
|
|
793
|
+
const envPath = join(CONFIG_DIR, '.env.local');
|
|
794
|
+
const envVars = parseEnvFile(envPath);
|
|
795
|
+
const currentReview = envVars.REVIEW_MODEL || 'claude-sonnet-4-6';
|
|
796
|
+
const currentExecute = envVars.EXECUTE_MODEL || 'claude-opus-4-6';
|
|
797
|
+
// No args: show current models and available options
|
|
798
|
+
if (args.length === 0) {
|
|
799
|
+
console.log(`\n${BOLD}Current models:${RESET}`);
|
|
800
|
+
const reviewAlias = KNOWN_MODELS.find((m) => m.id === currentReview)?.alias;
|
|
801
|
+
const executeAlias = KNOWN_MODELS.find((m) => m.id === currentExecute)?.alias;
|
|
802
|
+
console.log(` Review: ${GREEN}${currentReview}${RESET}${reviewAlias ? ` (${reviewAlias})` : ''}`);
|
|
803
|
+
console.log(` Execute: ${GREEN}${currentExecute}${RESET}${executeAlias ? ` (${executeAlias})` : ''}`);
|
|
804
|
+
console.log(`\n${BOLD}Available models:${RESET}`);
|
|
805
|
+
for (const m of KNOWN_MODELS) {
|
|
806
|
+
console.log(` ${BOLD}${m.alias.padEnd(10)}${RESET} ${DIM}${m.id.padEnd(35)}${RESET} ${m.description}`);
|
|
807
|
+
}
|
|
808
|
+
console.log(`\n${BOLD}Usage:${RESET}`);
|
|
809
|
+
console.log(` ticket-to-pr model review sonnet ${DIM}# set review model${RESET}`);
|
|
810
|
+
console.log(` ticket-to-pr model execute opus ${DIM}# set execute model${RESET}`);
|
|
811
|
+
console.log(` ticket-to-pr model both haiku ${DIM}# set both at once${RESET}\n`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Parse: model <agent> <model>
|
|
815
|
+
if (args.length < 2) {
|
|
816
|
+
console.log(`${RED}Usage: ticket-to-pr model <review|execute|both> <model>${RESET}`);
|
|
817
|
+
console.log(`${DIM}Run "ticket-to-pr model" to see available models.${RESET}`);
|
|
818
|
+
process.exitCode = 1;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const agent = args[0].toLowerCase();
|
|
822
|
+
const modelInput = args[1];
|
|
823
|
+
if (!['review', 'execute', 'both'].includes(agent)) {
|
|
824
|
+
console.log(`${RED}Unknown agent "${args[0]}". Use: review, execute, or both${RESET}`);
|
|
825
|
+
process.exitCode = 1;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const resolved = resolveModel(modelInput);
|
|
829
|
+
if (!resolved) {
|
|
830
|
+
console.log(`${RED}Unknown model "${modelInput}".${RESET}`);
|
|
831
|
+
console.log(`\n${BOLD}Available models:${RESET}`);
|
|
832
|
+
for (const m of KNOWN_MODELS) {
|
|
833
|
+
console.log(` ${BOLD}${m.alias.padEnd(10)}${RESET} ${DIM}${m.id}${RESET}`);
|
|
834
|
+
}
|
|
835
|
+
console.log(`\n${DIM}You can also pass a full model ID like claude-sonnet-4-5-20250929${RESET}`);
|
|
836
|
+
process.exitCode = 1;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const updates = {};
|
|
840
|
+
if (agent === 'review' || agent === 'both')
|
|
841
|
+
updates.REVIEW_MODEL = resolved.id;
|
|
842
|
+
if (agent === 'execute' || agent === 'both')
|
|
843
|
+
updates.EXECUTE_MODEL = resolved.id;
|
|
844
|
+
writeEnvFile(envPath, updates);
|
|
845
|
+
if (updates.REVIEW_MODEL) {
|
|
846
|
+
printStatus(true, 'Review model', `${resolved.id}${resolved.alias !== resolved.id ? ` (${resolved.alias})` : ''}`);
|
|
847
|
+
}
|
|
848
|
+
if (updates.EXECUTE_MODEL) {
|
|
849
|
+
printStatus(true, 'Execute model', `${resolved.id}${resolved.alias !== resolved.id ? ` (${resolved.alias})` : ''}`);
|
|
850
|
+
}
|
|
851
|
+
console.log(`${DIM}Saved to .env.local. Takes effect on next poll cycle.${RESET}\n`);
|
|
852
|
+
}
|
|
853
|
+
// -- Learnings --
|
|
854
|
+
export async function runLearnings(args) {
|
|
855
|
+
const projectNames = getProjectNames();
|
|
856
|
+
if (projectNames.length === 0) {
|
|
857
|
+
console.log(`${RED}No projects configured.${RESET} Run ${DIM}ticket-to-pr init${RESET} first.`);
|
|
858
|
+
process.exitCode = 1;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const subCmd = args[0]?.toLowerCase();
|
|
862
|
+
const projectArg = args[1];
|
|
863
|
+
// ticket-to-pr learnings clear <project>
|
|
864
|
+
if (subCmd === 'clear') {
|
|
865
|
+
if (!projectArg) {
|
|
866
|
+
console.log(`${RED}Usage: ticket-to-pr learnings clear <project>${RESET}`);
|
|
867
|
+
console.log(`${DIM}Projects: ${projectNames.join(', ')}${RESET}`);
|
|
868
|
+
process.exitCode = 1;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const dir = getProjectDir(projectArg);
|
|
872
|
+
if (!dir) {
|
|
873
|
+
console.log(`${RED}Unknown project "${projectArg}".${RESET} Available: ${projectNames.join(', ')}`);
|
|
874
|
+
process.exitCode = 1;
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const learningsPath = join(dir, '.ticket-to-pr', 'learnings.md');
|
|
878
|
+
if (existsSync(learningsPath)) {
|
|
879
|
+
unlinkSync(learningsPath);
|
|
880
|
+
printStatus(true, `Cleared learnings for ${projectArg}`);
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
console.log(`${DIM}No learnings file found for ${projectArg}.${RESET}`);
|
|
884
|
+
}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
// ticket-to-pr learnings [project]
|
|
888
|
+
// If a project name is given, show only that project
|
|
889
|
+
const projectsToShow = subCmd && projectNames.includes(subCmd)
|
|
890
|
+
? [subCmd]
|
|
891
|
+
: subCmd && getProjectDir(subCmd)
|
|
892
|
+
? [subCmd]
|
|
893
|
+
: projectNames;
|
|
894
|
+
// If an unknown arg was passed
|
|
895
|
+
if (subCmd && subCmd !== 'clear' && !getProjectDir(subCmd) && !projectNames.some(p => p.toLowerCase() === subCmd)) {
|
|
896
|
+
console.log(`${RED}Unknown project or subcommand "${args[0]}".${RESET}`);
|
|
897
|
+
console.log(`\n${BOLD}Usage:${RESET}`);
|
|
898
|
+
console.log(` ticket-to-pr learnings ${DIM}# view all projects${RESET}`);
|
|
899
|
+
console.log(` ticket-to-pr learnings <project> ${DIM}# view one project${RESET}`);
|
|
900
|
+
console.log(` ticket-to-pr learnings clear <project> ${DIM}# clear a project's learnings${RESET}`);
|
|
901
|
+
console.log(`\n${DIM}Projects: ${projectNames.join(', ')}${RESET}`);
|
|
902
|
+
process.exitCode = 1;
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
let anyFound = false;
|
|
906
|
+
for (const name of projectsToShow) {
|
|
907
|
+
const dir = getProjectDir(name);
|
|
908
|
+
if (!dir)
|
|
909
|
+
continue;
|
|
910
|
+
const content = readLearnings(dir);
|
|
911
|
+
if (content) {
|
|
912
|
+
anyFound = true;
|
|
913
|
+
console.log(`\n${BOLD}${name}${RESET} ${DIM}${dir}/.ticket-to-pr/learnings.md${RESET}\n`);
|
|
914
|
+
console.log(content);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
console.log(`\n${BOLD}${name}${RESET} ${DIM}no learnings yet${RESET}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!anyFound) {
|
|
921
|
+
console.log(`\n${DIM}Learnings accumulate automatically as tickets are processed.${RESET}`);
|
|
922
|
+
}
|
|
923
|
+
console.log(`\n${BOLD}Commands:${RESET}`);
|
|
924
|
+
console.log(` ticket-to-pr learnings ${DIM}# view all projects${RESET}`);
|
|
925
|
+
console.log(` ticket-to-pr learnings <project> ${DIM}# view one project${RESET}`);
|
|
926
|
+
console.log(` ticket-to-pr learnings clear <project> ${DIM}# clear a project's learnings${RESET}\n`);
|
|
927
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -11,10 +11,13 @@ export declare const CONFIG: {
|
|
|
11
11
|
};
|
|
12
12
|
readonly REVIEW_BUDGET_USD: 2;
|
|
13
13
|
readonly EXECUTE_BUDGET_USD: 15;
|
|
14
|
+
readonly DIFF_REVIEW_BUDGET_USD: 0.5;
|
|
14
15
|
readonly REVIEW_MODEL: string;
|
|
15
16
|
readonly EXECUTE_MODEL: string;
|
|
17
|
+
readonly DIFF_REVIEW_MODEL: string;
|
|
16
18
|
readonly REVIEW_MAX_TURNS: 25;
|
|
17
19
|
readonly EXECUTE_MAX_TURNS: 50;
|
|
20
|
+
readonly DIFF_REVIEW_MAX_TURNS: 10;
|
|
18
21
|
readonly STALE_LOCK_MS: number;
|
|
19
22
|
readonly MAX_CONCURRENT_AGENTS: number;
|
|
20
23
|
readonly FREE_MAX_PROJECTS: 1;
|
|
@@ -47,8 +50,32 @@ export declare const REVIEW_OUTPUT_SCHEMA: {
|
|
|
47
50
|
readonly risks: {
|
|
48
51
|
readonly type: "string";
|
|
49
52
|
};
|
|
53
|
+
readonly testCases: {
|
|
54
|
+
readonly type: "array";
|
|
55
|
+
readonly items: {
|
|
56
|
+
readonly type: "string";
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
readonly required: readonly ["easeScore", "confidenceScore", "spec", "impactReport", "affectedFiles", "testCases"];
|
|
61
|
+
};
|
|
62
|
+
export declare const DIFF_REVIEW_SCHEMA: {
|
|
63
|
+
readonly type: "object";
|
|
64
|
+
readonly properties: {
|
|
65
|
+
readonly approved: {
|
|
66
|
+
readonly type: "boolean";
|
|
67
|
+
};
|
|
68
|
+
readonly issues: {
|
|
69
|
+
readonly type: "array";
|
|
70
|
+
readonly items: {
|
|
71
|
+
readonly type: "string";
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
readonly summary: {
|
|
75
|
+
readonly type: "string";
|
|
76
|
+
};
|
|
50
77
|
};
|
|
51
|
-
readonly required: readonly ["
|
|
78
|
+
readonly required: readonly ["approved", "issues", "summary"];
|
|
52
79
|
};
|
|
53
80
|
export interface NotionTicket {
|
|
54
81
|
id: string;
|
|
@@ -69,6 +96,12 @@ export interface ReviewOutput {
|
|
|
69
96
|
impactReport: string;
|
|
70
97
|
affectedFiles: string[];
|
|
71
98
|
risks?: string;
|
|
99
|
+
testCases: string[];
|
|
100
|
+
}
|
|
101
|
+
export interface DiffReviewOutput {
|
|
102
|
+
approved: boolean;
|
|
103
|
+
issues: string[];
|
|
104
|
+
summary: string;
|
|
72
105
|
}
|
|
73
106
|
export interface LockEntry {
|
|
74
107
|
mode: 'review' | 'execute';
|
package/dist/config.js
CHANGED
|
@@ -46,6 +46,7 @@ export const CONFIG = {
|
|
|
46
46
|
// Agent budgets
|
|
47
47
|
REVIEW_BUDGET_USD: 2.00,
|
|
48
48
|
EXECUTE_BUDGET_USD: 15.00,
|
|
49
|
+
DIFF_REVIEW_BUDGET_USD: 0.50,
|
|
49
50
|
// Agent models (env override → default)
|
|
50
51
|
get REVIEW_MODEL() {
|
|
51
52
|
return process.env.REVIEW_MODEL || 'claude-sonnet-4-6';
|
|
@@ -53,9 +54,13 @@ export const CONFIG = {
|
|
|
53
54
|
get EXECUTE_MODEL() {
|
|
54
55
|
return process.env.EXECUTE_MODEL || 'claude-opus-4-6';
|
|
55
56
|
},
|
|
57
|
+
get DIFF_REVIEW_MODEL() {
|
|
58
|
+
return process.env.DIFF_REVIEW_MODEL || 'claude-haiku-4-5-20251001';
|
|
59
|
+
},
|
|
56
60
|
// Agent limits
|
|
57
61
|
REVIEW_MAX_TURNS: 25,
|
|
58
62
|
EXECUTE_MAX_TURNS: 50,
|
|
63
|
+
DIFF_REVIEW_MAX_TURNS: 10,
|
|
59
64
|
// Stale lock timeout (30 minutes)
|
|
60
65
|
STALE_LOCK_MS: 30 * 60 * 1000,
|
|
61
66
|
// Maximum concurrent agents (review + execute combined)
|
|
@@ -75,6 +80,17 @@ export const REVIEW_OUTPUT_SCHEMA = {
|
|
|
75
80
|
impactReport: { type: 'string' },
|
|
76
81
|
affectedFiles: { type: 'array', items: { type: 'string' } },
|
|
77
82
|
risks: { type: 'string' },
|
|
83
|
+
testCases: { type: 'array', items: { type: 'string' } },
|
|
84
|
+
},
|
|
85
|
+
required: ['easeScore', 'confidenceScore', 'spec', 'impactReport', 'affectedFiles', 'testCases'],
|
|
86
|
+
};
|
|
87
|
+
// JSON schema for diff review agent structured output
|
|
88
|
+
export const DIFF_REVIEW_SCHEMA = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
approved: { type: 'boolean' },
|
|
92
|
+
issues: { type: 'array', items: { type: 'string' } },
|
|
93
|
+
summary: { type: 'string' },
|
|
78
94
|
},
|
|
79
|
-
required: ['
|
|
95
|
+
required: ['approved', 'issues', 'summary'],
|
|
80
96
|
};
|
package/dist/index.js
CHANGED
|
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
5
|
-
import { CONFIG, REVIEW_OUTPUT_SCHEMA, isPro } from './config.js';
|
|
6
|
-
import { sleep, clamp, extractJsonFromOutput, shellEscape, extractNumber, loadEnv, parseEnvFile, createWorktree, removeWorktree, getDefaultBranch, validateNoBlockedFiles } from './lib/utils.js';
|
|
5
|
+
import { CONFIG, REVIEW_OUTPUT_SCHEMA, DIFF_REVIEW_SCHEMA, isPro } from './config.js';
|
|
6
|
+
import { sleep, clamp, extractJsonFromOutput, shellEscape, extractNumber, loadEnv, parseEnvFile, createWorktree, removeWorktree, getDefaultBranch, validateNoBlockedFiles, readLearnings, appendLearning } from './lib/utils.js';
|
|
7
7
|
import { getProjectDir, getProjectNames, getBuildCommand, getBaseBranch, getBlockedFiles, getSkipPR, getDevAccess, getEnvFile } 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';
|
|
@@ -13,9 +13,17 @@ loadEnv(join(CONFIG_DIR, '.env.local'));
|
|
|
13
13
|
delete process.env.CLAUDECODE;
|
|
14
14
|
// -- Subcommand routing --
|
|
15
15
|
const subcommand = process.argv[2];
|
|
16
|
-
if (subcommand === 'init' || subcommand === 'doctor') {
|
|
17
|
-
const { runInit, runDoctor } = await import('./cli.js');
|
|
18
|
-
|
|
16
|
+
if (subcommand === 'init' || subcommand === 'doctor' || subcommand === 'model' || subcommand === 'learnings') {
|
|
17
|
+
const { runInit, runDoctor, runModel, runLearnings } = await import('./cli.js');
|
|
18
|
+
if (subcommand === 'model') {
|
|
19
|
+
await runModel(process.argv.slice(3));
|
|
20
|
+
}
|
|
21
|
+
else if (subcommand === 'learnings') {
|
|
22
|
+
await runLearnings(process.argv.slice(3));
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
await (subcommand === 'init' ? runInit() : runDoctor());
|
|
26
|
+
}
|
|
19
27
|
process.exit(0);
|
|
20
28
|
}
|
|
21
29
|
// -- CLI flags --
|
|
@@ -43,6 +51,7 @@ function log(color, label, msg) {
|
|
|
43
51
|
// -- Prompt loading (bundled with the package) --
|
|
44
52
|
const reviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'review.md'), 'utf-8');
|
|
45
53
|
const executePrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'execute.md'), 'utf-8');
|
|
54
|
+
const diffReviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'diff-review.md'), 'utf-8');
|
|
46
55
|
// -- Agent Runner --
|
|
47
56
|
async function runReviewAgent(ticket) {
|
|
48
57
|
const projectDir = getProjectDir(ticket.project);
|
|
@@ -67,6 +76,10 @@ async function runReviewAgent(ticket) {
|
|
|
67
76
|
if (blockedFiles.length > 0) {
|
|
68
77
|
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
78
|
}
|
|
79
|
+
const learnings = readLearnings(projectDir);
|
|
80
|
+
if (learnings) {
|
|
81
|
+
promptParts.push('', '## Project Learnings', 'These are patterns and lessons learned from previous work on this project:', '', learnings);
|
|
82
|
+
}
|
|
70
83
|
const prompt = promptParts.join('\n');
|
|
71
84
|
const messages = query({
|
|
72
85
|
prompt,
|
|
@@ -134,6 +147,7 @@ async function runReviewAgent(ticket) {
|
|
|
134
147
|
impactReport: String(parsed.impactReport ?? ''),
|
|
135
148
|
affectedFiles: Array.isArray(parsed.affectedFiles) ? parsed.affectedFiles.map(String) : [],
|
|
136
149
|
risks: parsed.risks ? String(parsed.risks) : undefined,
|
|
150
|
+
testCases: Array.isArray(parsed.testCases) ? parsed.testCases.map(String) : [],
|
|
137
151
|
};
|
|
138
152
|
await writeReviewResults(ticket.id, results);
|
|
139
153
|
await moveTicketStatus(ticket.id, CONFIG.COLUMNS.SCORED);
|
|
@@ -146,8 +160,121 @@ async function runReviewAgent(ticket) {
|
|
|
146
160
|
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
147
161
|
].join('\n');
|
|
148
162
|
await addComment(ticket.id, comment);
|
|
163
|
+
appendLearning(projectDir, [
|
|
164
|
+
`**Review: ${ticket.title}**`,
|
|
165
|
+
`Ease: ${results.easeScore}/10, Confidence: ${results.confidenceScore}/10`,
|
|
166
|
+
`Affected files: ${results.affectedFiles.join(', ')}`,
|
|
167
|
+
results.risks ? `Risks: ${results.risks}` : '',
|
|
168
|
+
].filter(Boolean).join('\n'));
|
|
149
169
|
log(GREEN, 'REVIEW', `Done: ease=${results.easeScore} confidence=${results.confidenceScore} cost=$${cost.toFixed(2)}`);
|
|
150
170
|
}
|
|
171
|
+
async function runDiffReviewAgent(worktreeDir, baseBranch, spec, description, affectedFiles) {
|
|
172
|
+
// Get the full diff
|
|
173
|
+
let diff = '';
|
|
174
|
+
try {
|
|
175
|
+
diff = execSync(`git diff origin/${shellEscape(baseBranch)}...HEAD`, { cwd: worktreeDir, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }).toString();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// If diff fails (e.g. no commits), treat as empty
|
|
179
|
+
diff = '';
|
|
180
|
+
}
|
|
181
|
+
// Nothing to review if no changes
|
|
182
|
+
if (!diff.trim()) {
|
|
183
|
+
return {
|
|
184
|
+
result: { approved: true, issues: [], summary: 'No changes to review' },
|
|
185
|
+
cost: 0,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Truncate very large diffs to stay within budget
|
|
189
|
+
const maxDiffLength = 100_000;
|
|
190
|
+
const truncatedDiff = diff.length > maxDiffLength
|
|
191
|
+
? diff.slice(0, maxDiffLength) + '\n\n... (diff truncated)'
|
|
192
|
+
: diff;
|
|
193
|
+
const prompt = [
|
|
194
|
+
diffReviewPrompt,
|
|
195
|
+
'',
|
|
196
|
+
'## Spec',
|
|
197
|
+
spec || '(no spec provided)',
|
|
198
|
+
'',
|
|
199
|
+
'## Ticket Description',
|
|
200
|
+
description || '(no description provided)',
|
|
201
|
+
'',
|
|
202
|
+
'## Affected Files (from review)',
|
|
203
|
+
affectedFiles.length > 0 ? affectedFiles.map(f => `- ${f}`).join('\n') : '(none listed)',
|
|
204
|
+
'',
|
|
205
|
+
'## Diff',
|
|
206
|
+
'```diff',
|
|
207
|
+
truncatedDiff,
|
|
208
|
+
'```',
|
|
209
|
+
].join('\n');
|
|
210
|
+
const messages = query({
|
|
211
|
+
prompt,
|
|
212
|
+
options: {
|
|
213
|
+
model: CONFIG.DIFF_REVIEW_MODEL,
|
|
214
|
+
cwd: worktreeDir,
|
|
215
|
+
allowedTools: ['Read'],
|
|
216
|
+
maxTurns: CONFIG.DIFF_REVIEW_MAX_TURNS,
|
|
217
|
+
maxBudgetUsd: CONFIG.DIFF_REVIEW_BUDGET_USD,
|
|
218
|
+
permissionMode: 'bypassPermissions',
|
|
219
|
+
allowDangerouslySkipPermissions: true,
|
|
220
|
+
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
221
|
+
outputFormat: {
|
|
222
|
+
type: 'json_schema',
|
|
223
|
+
schema: DIFF_REVIEW_SCHEMA,
|
|
224
|
+
},
|
|
225
|
+
stderr: (data) => {
|
|
226
|
+
if (data.trim())
|
|
227
|
+
log(DIM, 'STDERR', data.trim());
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
let output = '';
|
|
232
|
+
let structuredOutput = undefined;
|
|
233
|
+
let cost = 0;
|
|
234
|
+
for await (const message of messages) {
|
|
235
|
+
if (message.type === 'assistant') {
|
|
236
|
+
const content = message.message?.content;
|
|
237
|
+
if (Array.isArray(content)) {
|
|
238
|
+
for (const block of content) {
|
|
239
|
+
if (block.type === 'text') {
|
|
240
|
+
output = block.text;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (typeof content === 'string') {
|
|
245
|
+
output = content;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (message.type === 'result') {
|
|
249
|
+
cost = message.total_cost_usd ?? 0;
|
|
250
|
+
if (message.subtype !== 'success') {
|
|
251
|
+
throw new Error(`Diff review agent failed: ${message.subtype}`);
|
|
252
|
+
}
|
|
253
|
+
if ('structured_output' in message && message.structured_output != null) {
|
|
254
|
+
structuredOutput = message.structured_output;
|
|
255
|
+
}
|
|
256
|
+
if (message.result) {
|
|
257
|
+
output = message.result;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const parsed = structuredOutput ?? extractJsonFromOutput(output);
|
|
262
|
+
if (!parsed) {
|
|
263
|
+
// If we can't parse the output, default to approved to avoid blocking
|
|
264
|
+
return {
|
|
265
|
+
result: { approved: true, issues: [], summary: 'Diff review agent did not return structured output; defaulting to approved' },
|
|
266
|
+
cost,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
result: {
|
|
271
|
+
approved: Boolean(parsed.approved),
|
|
272
|
+
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
|
273
|
+
summary: String(parsed.summary ?? ''),
|
|
274
|
+
},
|
|
275
|
+
cost,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
151
278
|
async function runExecuteAgent(ticket) {
|
|
152
279
|
const projectDir = getProjectDir(ticket.project);
|
|
153
280
|
if (!projectDir) {
|
|
@@ -195,12 +322,20 @@ async function runExecuteAgent(ticket) {
|
|
|
195
322
|
'**Page Content**:',
|
|
196
323
|
ticket.bodyBlocks,
|
|
197
324
|
];
|
|
325
|
+
// Highlight acceptance tests if present in the spec
|
|
326
|
+
if (ticket.spec && ticket.spec.includes('## Acceptance Tests')) {
|
|
327
|
+
promptParts.push('', '**IMPORTANT**: The spec above includes Acceptance Tests. Write test files FIRST, then implement code to make them pass. Run the tests to verify.');
|
|
328
|
+
}
|
|
198
329
|
if (blockedFiles.length > 0) {
|
|
199
330
|
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}\``));
|
|
200
331
|
}
|
|
201
332
|
if (devAccess) {
|
|
202
333
|
promptParts.push('', '## DEV ENVIRONMENT ACCESS', 'You have access to run scripts and dev tools in this project. Use this to:', '- Write and run scripts to understand database schema or existing data', '- Hit local API endpoints with curl to understand response shapes', '- Run tests to verify your implementation', '- Use ORM tools (e.g. `npx prisma studio`) to inspect the data model', '', '### Rules', '- Do NOT run database migrations (`prisma migrate`, `db push`, `alembic`, etc.)', '- Do NOT drop, truncate, or bulk-delete data', '- Do NOT make requests to external/production hosts — only localhost and 127.0.0.1', '- Clean up any temporary scripts you create before your final commit', '- If you create test data, document it in a commit message so reviewers know');
|
|
203
334
|
}
|
|
335
|
+
const learnings = readLearnings(projectDir);
|
|
336
|
+
if (learnings) {
|
|
337
|
+
promptParts.push('', '## Project Learnings', 'These are patterns and lessons learned from previous work on this project:', '', learnings);
|
|
338
|
+
}
|
|
204
339
|
const prompt = promptParts.join('\n');
|
|
205
340
|
// Build agent environment when envFile is configured
|
|
206
341
|
let agentEnv;
|
|
@@ -262,6 +397,14 @@ async function runExecuteAgent(ticket) {
|
|
|
262
397
|
// If branch doesn't exist or no commits, count is 0
|
|
263
398
|
commitCount = 0;
|
|
264
399
|
}
|
|
400
|
+
// Post-execution: diff review
|
|
401
|
+
log(YELLOW, 'REVIEW', 'Running diff review...');
|
|
402
|
+
const diffReview = await runDiffReviewAgent(worktreeDir, baseBranch, ticket.spec ?? '', ticket.description, []);
|
|
403
|
+
cost += diffReview.cost;
|
|
404
|
+
if (!diffReview.result.approved) {
|
|
405
|
+
throw new Error(`Diff review failed:\n${diffReview.result.issues.map(i => ` - ${i}`).join('\n')}`);
|
|
406
|
+
}
|
|
407
|
+
log(GREEN, 'REVIEW', `Diff review passed: ${diffReview.result.summary}`);
|
|
265
408
|
// Post-execution: validate build
|
|
266
409
|
const buildCmd = getBuildCommand(ticket.project);
|
|
267
410
|
let buildPassed = true;
|
|
@@ -341,6 +484,11 @@ async function runExecuteAgent(ticket) {
|
|
|
341
484
|
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
342
485
|
].join('\n');
|
|
343
486
|
await addComment(ticket.id, comment);
|
|
487
|
+
appendLearning(projectDir, [
|
|
488
|
+
`**Execute: ${ticket.title}**`,
|
|
489
|
+
`Branch: ${branchName}, Commits: ${commitCount}`,
|
|
490
|
+
`Cost: $${cost.toFixed(2)}`,
|
|
491
|
+
].join('\n'));
|
|
344
492
|
log(GREEN, 'EXECUTE', `Done: branch=${branchName} cost=$${cost.toFixed(2)}${prUrl ? ` pr=${prUrl}` : ''}`);
|
|
345
493
|
}
|
|
346
494
|
catch (error) {
|
|
@@ -354,6 +502,10 @@ async function runExecuteAgent(ticket) {
|
|
|
354
502
|
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
355
503
|
].join('\n');
|
|
356
504
|
await addComment(ticket.id, comment);
|
|
505
|
+
appendLearning(projectDir, [
|
|
506
|
+
`**Failed execute: ${ticket.title}**`,
|
|
507
|
+
`Error: ${errMsg.slice(0, 200)}`,
|
|
508
|
+
].join('\n'));
|
|
357
509
|
throw error;
|
|
358
510
|
}
|
|
359
511
|
finally {
|
|
@@ -399,6 +551,16 @@ async function handleTicket(mode, ticket) {
|
|
|
399
551
|
].join('\n');
|
|
400
552
|
await addComment(ticket.id, comment);
|
|
401
553
|
}
|
|
554
|
+
// Append failure learning (execute handles its own in runExecuteAgent catch)
|
|
555
|
+
if (mode === 'review') {
|
|
556
|
+
const projectDir = getProjectDir(ticket.project);
|
|
557
|
+
if (projectDir) {
|
|
558
|
+
appendLearning(projectDir, [
|
|
559
|
+
`**Failed review: ${ticket.title}**`,
|
|
560
|
+
`Error: ${errMsg.slice(0, 200)}`,
|
|
561
|
+
].join('\n'));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
402
564
|
try {
|
|
403
565
|
await writeFailure(ticket.id, errMsg);
|
|
404
566
|
}
|
package/dist/lib/notion.js
CHANGED
|
@@ -129,12 +129,17 @@ export async function fetchTicketDetails(pageId) {
|
|
|
129
129
|
* Write review results back to the ticket properties.
|
|
130
130
|
*/
|
|
131
131
|
export async function writeReviewResults(pageId, results) {
|
|
132
|
+
// Build spec content, appending test cases if present
|
|
133
|
+
let specContent = results.spec;
|
|
134
|
+
if (results.testCases && results.testCases.length > 0) {
|
|
135
|
+
specContent += '\n\n## Acceptance Tests\n' + results.testCases.map(tc => `- ${tc}`).join('\n');
|
|
136
|
+
}
|
|
132
137
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
138
|
const properties = {
|
|
134
139
|
Ease: { number: results.easeScore },
|
|
135
140
|
Confidence: { number: results.confidenceScore },
|
|
136
141
|
Spec: {
|
|
137
|
-
rich_text: chunkRichText(
|
|
142
|
+
rich_text: chunkRichText(specContent),
|
|
138
143
|
},
|
|
139
144
|
Impact: {
|
|
140
145
|
rich_text: chunkRichText(`${results.impactReport}\n\nFiles: ${results.affectedFiles.join(', ')}${results.risks ? `\n\nRisks: ${results.risks}` : ''}`),
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -26,3 +26,5 @@ export declare function ensureWorktreesIgnored(projectDir: string): void;
|
|
|
26
26
|
export declare function createWorktree(projectDir: string, branchName: string, worktreeDir: string, baseBranch?: string): void;
|
|
27
27
|
export declare function validateNoBlockedFiles(worktreeDir: string, baseBranch: string, blockedPatterns: string[]): string[];
|
|
28
28
|
export declare function removeWorktree(projectDir: string, worktreeDir: string): void;
|
|
29
|
+
export declare function readLearnings(projectDir: string): string;
|
|
30
|
+
export declare function appendLearning(projectDir: string, entry: string): void;
|
package/dist/lib/utils.js
CHANGED
|
@@ -204,11 +204,21 @@ export function _resetDefaultBranchCache() {
|
|
|
204
204
|
export function ensureWorktreesIgnored(projectDir) {
|
|
205
205
|
const gitignorePath = join(projectDir, '.gitignore');
|
|
206
206
|
try {
|
|
207
|
-
|
|
207
|
+
let content = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
208
208
|
const lines = content.split('\n');
|
|
209
|
+
let modified = false;
|
|
209
210
|
if (!lines.some((line) => line.trim() === '.worktrees' || line.trim() === '.worktrees/')) {
|
|
210
211
|
const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
211
|
-
|
|
212
|
+
content = content + separator + '.worktrees/\n';
|
|
213
|
+
modified = true;
|
|
214
|
+
}
|
|
215
|
+
if (!lines.some((line) => line.trim() === '.ticket-to-pr' || line.trim() === '.ticket-to-pr/')) {
|
|
216
|
+
const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
217
|
+
content = content + separator + '.ticket-to-pr/\n';
|
|
218
|
+
modified = true;
|
|
219
|
+
}
|
|
220
|
+
if (modified) {
|
|
221
|
+
writeFileSync(gitignorePath, content);
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
catch {
|
|
@@ -351,3 +361,36 @@ export function removeWorktree(projectDir, worktreeDir) {
|
|
|
351
361
|
}
|
|
352
362
|
}
|
|
353
363
|
}
|
|
364
|
+
// -- Per-project learnings --
|
|
365
|
+
const MAX_LEARNINGS_ENTRIES = 100;
|
|
366
|
+
export function readLearnings(projectDir) {
|
|
367
|
+
const learningsPath = join(projectDir, '.ticket-to-pr', 'learnings.md');
|
|
368
|
+
try {
|
|
369
|
+
return readFileSync(learningsPath, 'utf-8');
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return '';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
export function appendLearning(projectDir, entry) {
|
|
376
|
+
const dir = join(projectDir, '.ticket-to-pr');
|
|
377
|
+
mkdirSync(dir, { recursive: true });
|
|
378
|
+
const learningsPath = join(dir, 'learnings.md');
|
|
379
|
+
let content = '';
|
|
380
|
+
try {
|
|
381
|
+
content = readFileSync(learningsPath, 'utf-8');
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// File doesn't exist yet
|
|
385
|
+
}
|
|
386
|
+
// Add timestamped entry
|
|
387
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
388
|
+
const newEntry = `### ${timestamp}\n${entry}\n`;
|
|
389
|
+
content = content + '\n' + newEntry;
|
|
390
|
+
// Trim to max entries
|
|
391
|
+
const entries = content.split(/(?=^### \d{4}-\d{2}-\d{2}$)/m).filter(e => e.trim());
|
|
392
|
+
if (entries.length > MAX_LEARNINGS_ENTRIES) {
|
|
393
|
+
content = entries.slice(-MAX_LEARNINGS_ENTRIES).join('');
|
|
394
|
+
}
|
|
395
|
+
writeFileSync(learningsPath, content.trim() + '\n');
|
|
396
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
You are a code reviewer checking a diff against its specification.
|
|
2
|
+
|
|
3
|
+
## Your Task
|
|
4
|
+
1. Read the diff carefully
|
|
5
|
+
2. Compare it against the original spec and ticket description
|
|
6
|
+
3. Check for common issues
|
|
7
|
+
4. Approve or reject with specific reasons
|
|
8
|
+
|
|
9
|
+
## Check For
|
|
10
|
+
- Does the diff implement what the spec asked for?
|
|
11
|
+
- Are there modified files not mentioned in the affected files list?
|
|
12
|
+
- Any hardcoded values, debug code, console.logs, or TODOs left behind?
|
|
13
|
+
- Any obvious security issues (exposed secrets, SQL injection, XSS)?
|
|
14
|
+
- Any deleted tests or reduced test coverage?
|
|
15
|
+
- Are imports and exports consistent?
|
|
16
|
+
- Does the code follow the patterns visible in the diff context?
|
|
17
|
+
|
|
18
|
+
## Output
|
|
19
|
+
Return a JSON object:
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"approved": true/false,
|
|
23
|
+
"issues": ["issue 1", "issue 2"],
|
|
24
|
+
"summary": "Brief summary of the review"
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If approved is false, be specific about what needs to change. An empty issues array with approved: true means the diff looks good.
|
package/prompts/execute.md
CHANGED
|
@@ -17,5 +17,15 @@ You have been given a ticket with an implementation spec. Follow the spec and im
|
|
|
17
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.
|
|
18
18
|
12. If your prompt includes a "DEV ENVIRONMENT ACCESS" section, you may run scripts and dev tools as described. Always prefer reading code directly over running scripts when possible.
|
|
19
19
|
|
|
20
|
+
## Test-First Development
|
|
21
|
+
If the spec includes acceptance tests, follow this workflow:
|
|
22
|
+
1. Read the acceptance tests carefully before writing any code
|
|
23
|
+
2. Write a test file first that captures the acceptance criteria as executable tests
|
|
24
|
+
3. Implement the code to make the tests pass
|
|
25
|
+
4. Run the tests to verify your implementation
|
|
26
|
+
5. If tests fail, fix the implementation until they pass
|
|
27
|
+
|
|
28
|
+
If no test framework is configured in the project, implement the code directly but use the acceptance tests as a checklist — verify each criterion is met before committing.
|
|
29
|
+
|
|
20
30
|
## When Done
|
|
21
31
|
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
|
@@ -30,10 +30,23 @@ You MUST end your response with a JSON code block containing exactly these field
|
|
|
30
30
|
"spec": "<step-by-step implementation plan in markdown>",
|
|
31
31
|
"impactReport": "<which files change and why, in markdown>",
|
|
32
32
|
"affectedFiles": ["<file1>", "<file2>"],
|
|
33
|
-
"risks": "<any concerns or blockers, optional>"
|
|
33
|
+
"risks": "<any concerns or blockers, optional>",
|
|
34
|
+
"testCases": ["<test case 1>", "<test case 2>", "..."]
|
|
34
35
|
}
|
|
35
36
|
```
|
|
36
37
|
|
|
38
|
+
### Test Cases
|
|
39
|
+
|
|
40
|
+
Generate 3-8 acceptance test cases depending on ticket complexity. These are framework-agnostic acceptance criteria (not full test files) that the execute agent must satisfy.
|
|
41
|
+
|
|
42
|
+
- Write each test case as a "GIVEN... WHEN... THEN..." statement or a simple assertion
|
|
43
|
+
- Focus on verifiable outcomes, not implementation details
|
|
44
|
+
- Cover happy path, edge cases, and error handling as appropriate
|
|
45
|
+
- Examples:
|
|
46
|
+
- "GET /api/health returns 200 with JSON body containing status:'ok' and a valid ISO timestamp"
|
|
47
|
+
- "Calling formatDate(null) returns empty string"
|
|
48
|
+
- "GIVEN a user is not authenticated WHEN they request /api/private THEN they receive a 401 response"
|
|
49
|
+
|
|
37
50
|
## Rules
|
|
38
51
|
- DO NOT modify any files. You are read-only.
|
|
39
52
|
- Be honest about confidence. A low score is valuable information.
|