ticket-to-pr 1.3.0 → 1.4.1

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
@@ -214,6 +214,9 @@ ticket-to-pr --dry-run --once
214
214
  | `doctor` | Diagnostic check — verifies environment, Notion connectivity, database schema, tools, and projects |
215
215
  | `model` | View current models and available options |
216
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 |
217
220
  | *(none)* | Continuous polling every 30s |
218
221
  | `--once` | Poll once, wait for agents to finish, exit |
219
222
  | `--dry-run` | Poll and log what would happen, don't run agents |
@@ -351,6 +354,23 @@ Available model aliases:
351
354
 
352
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.
353
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
+
354
374
  ### Your First Ticket
355
375
 
356
376
  1. Click **"+ New"** on your Notion board
@@ -424,7 +444,7 @@ The review agent explores your codebase without modifying anything:
424
444
 
425
445
  - **Tools**: Read, Glob, Grep, Task
426
446
  - **Context**: Reads your project's `CLAUDE.md` for architecture rules. If `blockedFiles` are configured, the review agent factors those constraints into scoring.
427
- - **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**
428
448
  - **Budget**: $2.00 max, 25 turns max
429
449
  - **Typical cost**: $0.15 - $0.50
430
450
 
@@ -446,7 +466,7 @@ The review agent explores your codebase without modifying anything:
446
466
 
447
467
  ### Execute Agent (Write Access)
448
468
 
449
- 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.
450
470
 
451
471
  - **Tools**: Read, Glob, Grep, Edit, Write + limited Bash (git, build, test only)
452
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
@@ -459,14 +479,15 @@ The execute agent implements the code based on the spec:
459
479
 
460
480
  1. TicketToPR **fetches the latest** from `origin/<baseBranch>` (configurable per project, auto-detected by default)
461
481
  2. Creates branch `notion/{8-char-id}/{ticket-slug}` based on the fresh remote state
462
- 3. Claude implements changes and makes atomic commits
463
- 4. TicketToPR runs your build command (if configured)
464
- 5. If `blockedFiles` patterns are configured, validates no off-limits files were touched
465
- 6. Build passes + no blocked file violations: pushes branch to origin
466
- 7. Creates a GitHub PR via `gh pr create` targeting the base branch (unless `skipPR` is enabled)
467
- 8. PR URL written back to the Notion ticket
468
- 9. Ticket moves to **PR Ready**
469
- 10. Build fails or blocked file violation: no code is pushed, ticket moves to **Failed**
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**
470
491
 
471
492
  ## Costs
472
493
 
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export declare function runDoctor(): Promise<void>;
2
2
  export declare function runInit(): Promise<void>;
3
3
  export declare function runModel(args: string[]): Promise<void>;
4
+ export declare function runLearnings(args: string[]): Promise<void>;
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, parseEnvFile } 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 --
@@ -849,3 +850,78 @@ export async function runModel(args) {
849
850
  }
850
851
  console.log(`${DIM}Saved to .env.local. Takes effect on next poll cycle.${RESET}\n`);
851
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 ["easeScore", "confidenceScore", "spec", "impactReport", "affectedFiles"];
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: ['easeScore', 'confidenceScore', 'spec', 'impactReport', 'affectedFiles'],
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,11 +13,14 @@ 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' || subcommand === 'model') {
17
- const { runInit, runDoctor, runModel } = await import('./cli.js');
16
+ if (subcommand === 'init' || subcommand === 'doctor' || subcommand === 'model' || subcommand === 'learnings') {
17
+ const { runInit, runDoctor, runModel, runLearnings } = await import('./cli.js');
18
18
  if (subcommand === 'model') {
19
19
  await runModel(process.argv.slice(3));
20
20
  }
21
+ else if (subcommand === 'learnings') {
22
+ await runLearnings(process.argv.slice(3));
23
+ }
21
24
  else {
22
25
  await (subcommand === 'init' ? runInit() : runDoctor());
23
26
  }
@@ -48,6 +51,8 @@ function log(color, label, msg) {
48
51
  // -- Prompt loading (bundled with the package) --
49
52
  const reviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'review.md'), 'utf-8');
50
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');
55
+ const retroPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'retro.md'), 'utf-8');
51
56
  // -- Agent Runner --
52
57
  async function runReviewAgent(ticket) {
53
58
  const projectDir = getProjectDir(ticket.project);
@@ -72,6 +77,10 @@ async function runReviewAgent(ticket) {
72
77
  if (blockedFiles.length > 0) {
73
78
  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}\``));
74
79
  }
80
+ const learnings = readLearnings(projectDir);
81
+ if (learnings) {
82
+ promptParts.push('', '## Project Learnings', 'These are patterns and lessons learned from previous work on this project:', '', learnings);
83
+ }
75
84
  const prompt = promptParts.join('\n');
76
85
  const messages = query({
77
86
  prompt,
@@ -139,6 +148,7 @@ async function runReviewAgent(ticket) {
139
148
  impactReport: String(parsed.impactReport ?? ''),
140
149
  affectedFiles: Array.isArray(parsed.affectedFiles) ? parsed.affectedFiles.map(String) : [],
141
150
  risks: parsed.risks ? String(parsed.risks) : undefined,
151
+ testCases: Array.isArray(parsed.testCases) ? parsed.testCases.map(String) : [],
142
152
  };
143
153
  await writeReviewResults(ticket.id, results);
144
154
  await moveTicketStatus(ticket.id, CONFIG.COLUMNS.SCORED);
@@ -151,8 +161,223 @@ async function runReviewAgent(ticket) {
151
161
  `Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
152
162
  ].join('\n');
153
163
  await addComment(ticket.id, comment);
164
+ appendLearning(projectDir, [
165
+ `**Review: ${ticket.title}**`,
166
+ `Ease: ${results.easeScore}/10, Confidence: ${results.confidenceScore}/10`,
167
+ `Affected files: ${results.affectedFiles.join(', ')}`,
168
+ results.risks ? `Risks: ${results.risks}` : '',
169
+ ].filter(Boolean).join('\n'));
154
170
  log(GREEN, 'REVIEW', `Done: ease=${results.easeScore} confidence=${results.confidenceScore} cost=$${cost.toFixed(2)}`);
155
171
  }
172
+ async function runDiffReviewAgent(worktreeDir, baseBranch, spec, description, affectedFiles) {
173
+ // Get the full diff
174
+ let diff = '';
175
+ try {
176
+ diff = execSync(`git diff origin/${shellEscape(baseBranch)}...HEAD`, { cwd: worktreeDir, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }).toString();
177
+ }
178
+ catch {
179
+ // If diff fails (e.g. no commits), treat as empty
180
+ diff = '';
181
+ }
182
+ // Nothing to review if no changes
183
+ if (!diff.trim()) {
184
+ return {
185
+ result: { approved: true, issues: [], summary: 'No changes to review' },
186
+ cost: 0,
187
+ };
188
+ }
189
+ // Truncate very large diffs to stay within budget
190
+ const maxDiffLength = 100_000;
191
+ const truncatedDiff = diff.length > maxDiffLength
192
+ ? diff.slice(0, maxDiffLength) + '\n\n... (diff truncated)'
193
+ : diff;
194
+ const prompt = [
195
+ diffReviewPrompt,
196
+ '',
197
+ '## Spec',
198
+ spec || '(no spec provided)',
199
+ '',
200
+ '## Ticket Description',
201
+ description || '(no description provided)',
202
+ '',
203
+ '## Affected Files (from review)',
204
+ affectedFiles.length > 0 ? affectedFiles.map(f => `- ${f}`).join('\n') : '(none listed)',
205
+ '',
206
+ '## Diff',
207
+ '```diff',
208
+ truncatedDiff,
209
+ '```',
210
+ ].join('\n');
211
+ const messages = query({
212
+ prompt,
213
+ options: {
214
+ model: CONFIG.DIFF_REVIEW_MODEL,
215
+ cwd: worktreeDir,
216
+ allowedTools: ['Read'],
217
+ maxTurns: CONFIG.DIFF_REVIEW_MAX_TURNS,
218
+ maxBudgetUsd: CONFIG.DIFF_REVIEW_BUDGET_USD,
219
+ permissionMode: 'bypassPermissions',
220
+ allowDangerouslySkipPermissions: true,
221
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
222
+ outputFormat: {
223
+ type: 'json_schema',
224
+ schema: DIFF_REVIEW_SCHEMA,
225
+ },
226
+ stderr: (data) => {
227
+ if (data.trim())
228
+ log(DIM, 'STDERR', data.trim());
229
+ },
230
+ },
231
+ });
232
+ let output = '';
233
+ let structuredOutput = undefined;
234
+ let cost = 0;
235
+ for await (const message of messages) {
236
+ if (message.type === 'assistant') {
237
+ const content = message.message?.content;
238
+ if (Array.isArray(content)) {
239
+ for (const block of content) {
240
+ if (block.type === 'text') {
241
+ output = block.text;
242
+ }
243
+ }
244
+ }
245
+ else if (typeof content === 'string') {
246
+ output = content;
247
+ }
248
+ }
249
+ if (message.type === 'result') {
250
+ cost = message.total_cost_usd ?? 0;
251
+ if (message.subtype !== 'success') {
252
+ throw new Error(`Diff review agent failed: ${message.subtype}`);
253
+ }
254
+ if ('structured_output' in message && message.structured_output != null) {
255
+ structuredOutput = message.structured_output;
256
+ }
257
+ if (message.result) {
258
+ output = message.result;
259
+ }
260
+ }
261
+ }
262
+ const parsed = structuredOutput ?? extractJsonFromOutput(output);
263
+ if (!parsed) {
264
+ // If we can't parse the output, default to approved to avoid blocking
265
+ return {
266
+ result: { approved: true, issues: [], summary: 'Diff review agent did not return structured output; defaulting to approved' },
267
+ cost,
268
+ };
269
+ }
270
+ return {
271
+ result: {
272
+ approved: Boolean(parsed.approved),
273
+ issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
274
+ summary: String(parsed.summary ?? ''),
275
+ },
276
+ cost,
277
+ };
278
+ }
279
+ async function runRetroAgent(projectDir, worktreeDir, baseBranch, ticket, outcome) {
280
+ try {
281
+ // Get the diff (may be empty if agent failed before committing)
282
+ let diff = '';
283
+ try {
284
+ diff = execSync(`git diff origin/${shellEscape(baseBranch)}...HEAD`, {
285
+ cwd: worktreeDir, stdio: 'pipe', timeout: 15_000,
286
+ }).toString();
287
+ }
288
+ catch {
289
+ // No diff available
290
+ }
291
+ // Read existing learnings to avoid repeats
292
+ const existingLearnings = readLearnings(projectDir);
293
+ // Build the retro prompt
294
+ const parts = [
295
+ retroPrompt,
296
+ '',
297
+ `## Ticket: ${ticket.title}`,
298
+ '',
299
+ '**Description**:',
300
+ ticket.description,
301
+ '',
302
+ '**Spec**:',
303
+ ticket.spec ?? '(none)',
304
+ '',
305
+ `## Outcome: ${outcome.success ? 'SUCCESS' : 'FAILED'}`,
306
+ ];
307
+ if (!outcome.success && outcome.error) {
308
+ parts.push('', '**Error**:', outcome.error.slice(0, 1000));
309
+ }
310
+ if (outcome.diffReviewIssues && outcome.diffReviewIssues.length > 0) {
311
+ parts.push('', '**Diff Review Issues**:', ...outcome.diffReviewIssues.map(i => `- ${i}`));
312
+ }
313
+ if (outcome.buildFailed) {
314
+ parts.push('', '**Build**: FAILED');
315
+ }
316
+ if (diff) {
317
+ const truncatedDiff = diff.length > 50_000 ? diff.slice(0, 50_000) + '\n...(truncated)' : diff;
318
+ parts.push('', '## Diff', '```diff', truncatedDiff, '```');
319
+ }
320
+ else {
321
+ parts.push('', '## Diff', 'No diff available (agent may have failed before committing).');
322
+ }
323
+ if (existingLearnings) {
324
+ parts.push('', '## Existing Learnings (do not repeat these)', existingLearnings);
325
+ }
326
+ const prompt = parts.join('\n');
327
+ const messages = query({
328
+ prompt,
329
+ options: {
330
+ model: CONFIG.DIFF_REVIEW_MODEL, // Reuse Haiku config
331
+ cwd: projectDir,
332
+ tools: ['Read', 'Glob', 'Grep'],
333
+ allowedTools: ['Read', 'Glob', 'Grep'],
334
+ maxTurns: 5,
335
+ maxBudgetUsd: 0.25,
336
+ permissionMode: 'bypassPermissions',
337
+ allowDangerouslySkipPermissions: true,
338
+ settingSources: ['project'],
339
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
340
+ stderr: () => { },
341
+ },
342
+ });
343
+ let output = '';
344
+ for await (const message of messages) {
345
+ if (message.type === 'assistant') {
346
+ const content = message.message?.content;
347
+ if (Array.isArray(content)) {
348
+ for (const block of content) {
349
+ if (block.type === 'text')
350
+ output = block.text;
351
+ }
352
+ }
353
+ else if (typeof content === 'string') {
354
+ output = content;
355
+ }
356
+ }
357
+ if (message.type === 'result') {
358
+ if ('result' in message && message.result) {
359
+ output = message.result;
360
+ }
361
+ }
362
+ }
363
+ // Only save if the retro produced something meaningful
364
+ const trimmed = output.trim();
365
+ if (trimmed && !trimmed.toLowerCase().includes('no new learnings')) {
366
+ const header = outcome.success
367
+ ? `**Retro (success): ${ticket.title}**`
368
+ : `**Retro (failed): ${ticket.title}**`;
369
+ appendLearning(projectDir, `${header}\n${trimmed}`);
370
+ log(DIM, 'RETRO', `Saved ${trimmed.split('\n').filter(l => l.startsWith('-') || l.startsWith('*')).length} learnings`);
371
+ }
372
+ else {
373
+ log(DIM, 'RETRO', 'No new learnings');
374
+ }
375
+ }
376
+ catch (e) {
377
+ // Retro is best-effort — never block the pipeline
378
+ log(DIM, 'RETRO', `Skipped: ${e instanceof Error ? e.message : e}`);
379
+ }
380
+ }
156
381
  async function runExecuteAgent(ticket) {
157
382
  const projectDir = getProjectDir(ticket.project);
158
383
  if (!projectDir) {
@@ -181,6 +406,7 @@ async function runExecuteAgent(ticket) {
181
406
  createWorktree(projectDir, branchName, worktreeDir, baseBranch);
182
407
  let cost = 0;
183
408
  let commitCount = 0;
409
+ let retroOutcome = { success: false };
184
410
  try {
185
411
  const promptParts = [
186
412
  executePrompt,
@@ -200,12 +426,20 @@ async function runExecuteAgent(ticket) {
200
426
  '**Page Content**:',
201
427
  ticket.bodyBlocks,
202
428
  ];
429
+ // Highlight acceptance tests if present in the spec
430
+ if (ticket.spec && ticket.spec.includes('## Acceptance Tests')) {
431
+ 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.');
432
+ }
203
433
  if (blockedFiles.length > 0) {
204
434
  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}\``));
205
435
  }
206
436
  if (devAccess) {
207
437
  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');
208
438
  }
439
+ const learnings = readLearnings(projectDir);
440
+ if (learnings) {
441
+ promptParts.push('', '## Project Learnings', 'These are patterns and lessons learned from previous work on this project:', '', learnings);
442
+ }
209
443
  const prompt = promptParts.join('\n');
210
444
  // Build agent environment when envFile is configured
211
445
  let agentEnv;
@@ -267,6 +501,15 @@ async function runExecuteAgent(ticket) {
267
501
  // If branch doesn't exist or no commits, count is 0
268
502
  commitCount = 0;
269
503
  }
504
+ // Post-execution: diff review
505
+ log(YELLOW, 'REVIEW', 'Running diff review...');
506
+ const diffReview = await runDiffReviewAgent(worktreeDir, baseBranch, ticket.spec ?? '', ticket.description, []);
507
+ cost += diffReview.cost;
508
+ if (!diffReview.result.approved) {
509
+ retroOutcome = { success: false, error: 'Diff review rejected the changes', diffReviewIssues: diffReview.result.issues };
510
+ throw new Error(`Diff review failed:\n${diffReview.result.issues.map(i => ` - ${i}`).join('\n')}`);
511
+ }
512
+ log(GREEN, 'REVIEW', `Diff review passed: ${diffReview.result.summary}`);
270
513
  // Post-execution: validate build
271
514
  const buildCmd = getBuildCommand(ticket.project);
272
515
  let buildPassed = true;
@@ -346,6 +589,7 @@ async function runExecuteAgent(ticket) {
346
589
  `Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
347
590
  ].join('\n');
348
591
  await addComment(ticket.id, comment);
592
+ retroOutcome = { success: true };
349
593
  log(GREEN, 'EXECUTE', `Done: branch=${branchName} cost=$${cost.toFixed(2)}${prUrl ? ` pr=${prUrl}` : ''}`);
350
594
  }
351
595
  catch (error) {
@@ -359,9 +603,17 @@ async function runExecuteAgent(ticket) {
359
603
  `Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
360
604
  ].join('\n');
361
605
  await addComment(ticket.id, comment);
606
+ retroOutcome = {
607
+ success: false,
608
+ error: errMsg,
609
+ buildFailed: errMsg.includes('Build validation failed'),
610
+ };
362
611
  throw error;
363
612
  }
364
613
  finally {
614
+ // Run retro before cleaning up worktree (so it can read the diff)
615
+ log(DIM, 'RETRO', 'Running post-execution retrospective...');
616
+ await runRetroAgent(projectDir, worktreeDir, baseBranch, ticket, retroOutcome);
365
617
  // Always clean up the worktree
366
618
  removeWorktree(projectDir, worktreeDir);
367
619
  }
@@ -404,6 +656,16 @@ async function handleTicket(mode, ticket) {
404
656
  ].join('\n');
405
657
  await addComment(ticket.id, comment);
406
658
  }
659
+ // Append failure learning (execute handles its own in runExecuteAgent catch)
660
+ if (mode === 'review') {
661
+ const projectDir = getProjectDir(ticket.project);
662
+ if (projectDir) {
663
+ appendLearning(projectDir, [
664
+ `**Failed review: ${ticket.title}**`,
665
+ `Error: ${errMsg.slice(0, 200)}`,
666
+ ].join('\n'));
667
+ }
668
+ }
407
669
  try {
408
670
  await writeFailure(ticket.id, errMsg);
409
671
  }
@@ -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(results.spec),
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}` : ''}`),
@@ -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
- const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
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
- writeFileSync(gitignorePath, content + separator + '.worktrees/\n');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticket-to-pr",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Drag a Notion ticket, get a pull request. AI-powered dev automation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -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.
@@ -0,0 +1,27 @@
1
+ You are conducting a retrospective on an AI agent's work on a codebase.
2
+
3
+ ## Your Task
4
+ Analyze what happened during this ticket execution and extract lessons that will help future agent runs on this same project. You are writing notes for a future AI agent, not a human.
5
+
6
+ ## What to Look For
7
+
8
+ ### On Success
9
+ - **Conventions discovered**: file naming, import patterns, export style, component structure, API response shapes, error handling patterns
10
+ - **What worked**: approaches or patterns that led to clean implementation
11
+ - **Codebase quirks**: path aliases, custom configs, non-obvious setup requirements, framework-specific patterns
12
+
13
+ ### On Failure
14
+ - **Root cause**: what specifically went wrong and why (not just the error message)
15
+ - **What to do differently**: concrete, actionable advice for next time
16
+ - **Codebase constraints**: things the agent didn't know about that caused the failure
17
+
18
+ ### Always
19
+ - **Capability assessment**: what types of changes are easy/hard in this project
20
+ - **Suggestions**: improvements to the project's CLAUDE.md or configuration that would help future runs
21
+
22
+ ## Output Rules
23
+ - Write 2-5 bullet points. Each must be a specific, actionable lesson.
24
+ - Start each bullet with a category tag: `[convention]`, `[mistake]`, `[capability]`, or `[suggestion]`
25
+ - Be specific to THIS project. "Use TypeScript" is useless. "This project uses strict TypeScript with no implicit any — always add explicit return types on exported functions" is useful.
26
+ - Don't repeat lessons that already exist in the project learnings.
27
+ - If nothing useful was learned (e.g., trivial change, obvious outcome), just write: `No new learnings.`
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.