ticket-to-pr 1.4.1 → 1.4.2

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
@@ -74,14 +74,17 @@ Execute Claude creates branch, implements code, commits changes.
74
74
  In Progress Set automatically when the execute agent starts working.
75
75
  |
76
76
  v
77
- PR Ready Branch pushed. PR created on GitHub.
78
- Branch name, cost, and PR link written to ticket.
77
+ Testing Branch pushed. PR created. QA checklist posted as comment.
78
+ | Dev reviews PR, merges, deploys. Tester verifies in prod.
79
+ v
80
+ Done Tester drags here after verifying. Comments with feedback.
81
+ TicketToPR reads comments, extracts learnings, saves to project memory.
79
82
 
80
83
  Failed Agent errored. Error details on ticket.
81
84
  Drag back to Review or Execute to retry.
82
85
  ```
83
86
 
84
- The rhythm is **you, AI, you, AI, AI, you** — three human touchpoints, three AI steps. You're always the decision-maker. The AI is always the worker.
87
+ The rhythm is **you, AI, you, AI, AI, you, AI** — human touchpoints at Review, Execute, and Done. You're always the decision-maker. The AI is always the worker.
85
88
 
86
89
  ### What It's Great At
87
90
 
@@ -165,11 +168,15 @@ Create a new **Board view** database in Notion with these properties:
165
168
  | `Branch` | Text | Git branch name (written by AI) |
166
169
  | `Cost` | Text | USD spent on the Claude run |
167
170
  | `PR URL` | URL | GitHub pull request link (written by AI) |
171
+ | `Reviewed At` | Date | When review completed (sort Scored by this) |
172
+ | `Executed At` | Date | When execution completed (sort Testing by this) |
173
+ | `Failed At` | Date | When ticket failed (sort Failed by this) |
174
+ | `Done At` | Date | When feedback processed (sort Done by this) |
168
175
 
169
- Add these **7 status columns**:
176
+ Add these **8 status columns**:
170
177
 
171
178
  ```
172
- Backlog | Review | Scored | Execute | In Progress | PR Ready | Failed
179
+ Backlog | Review | Scored | Execute | In Progress | Testing | Done | Failed
173
180
  ```
174
181
 
175
182
  Connect the integration: **"..." menu** on the database page -> **Connections** -> search **TicketToPR** -> add it.
@@ -381,7 +388,7 @@ Learnings are stored in each project directory at `.ticket-to-pr/learnings.md` (
381
388
  6. Run `ticket-to-pr --once` and watch it score the ticket
382
389
  7. Check Notion — ticket should be in **Scored** with Ease, Confidence, Spec, Impact filled in
383
390
  8. Drag to **Execute**, run `ticket-to-pr --once` again
384
- 9. Check Notion — ticket should be in **PR Ready** with Branch, Cost, and PR link
391
+ 9. Check Notion — ticket should be in **Testing** with Branch, Cost, and PR link
385
392
 
386
393
  Typical cost for this test: **~$0.49** ($0.22 review + $0.27 execute).
387
394
 
@@ -486,9 +493,37 @@ The execute agent implements the code based on the spec. When the review agent g
486
493
  7. All checks pass: pushes branch to origin
487
494
  8. Creates a GitHub PR via `gh pr create` targeting the base branch (unless `skipPR` is enabled)
488
495
  9. PR URL written back to the Notion ticket
489
- 10. Ticket moves to **PR Ready**
496
+ 10. Ticket moves to **Testing**
490
497
  11. Any check fails (diff review, build, blocked files): no code is pushed, ticket moves to **Failed**
491
498
 
499
+ ## Human Feedback Loop
500
+
501
+ Non-technical team members can test results and give feedback directly in Notion. TicketToPR reads their comments and saves learnings to improve future runs.
502
+
503
+ ### Workflow for testers (PMs, founders, QA)
504
+
505
+ 1. A ticket lands in **Testing** with a QA checklist comment — the dev reviews and merges the PR, deploys
506
+ 4. Test the change and **comment on the ticket** with what you found
507
+ 5. **Drag the ticket**:
508
+ - To **Done** if it works
509
+ - To **Failed** if something's wrong (explain what happened in a comment)
510
+
511
+ That's it. TicketToPR reads your comments, extracts learnings, and saves them so the AI makes fewer mistakes over time.
512
+
513
+ ### Feedback on Failed tickets
514
+
515
+ When you drag a ticket to Failed and comment why, the system saves your context alongside the technical error. This is especially valuable because humans often know *why* something failed better than the error log — "this broke because we changed the API last week" or "wrong approach, we use Redis for this."
516
+
517
+ ### What gets saved
518
+
519
+ Comments are processed by a lightweight AI agent that extracts tagged learnings:
520
+ - `[feedback]` — general observations about the result
521
+ - `[preference]` — style or approach preferences ("put buttons in the header, not sidebar")
522
+ - `[bug]` — things that broke ("this change broke the checkout flow")
523
+ - `[quality]` — performance, UX, or code quality notes
524
+
525
+ These learnings are injected into future agent prompts for the same project.
526
+
492
527
  ## Costs
493
528
 
494
529
  TicketToPR itself is free. You pay Anthropic for Claude API usage. Based on real usage:
@@ -614,7 +649,7 @@ Add to `projects.json` (or re-run `ticket-to-pr init`):
614
649
  | Build validation fails | Ticket -> Failed with command, directory, and build output (up to 500 chars) |
615
650
  | Blocked file violation | Ticket -> Failed with list of matched files and patterns. No code is pushed. |
616
651
  | Push fails | Ticket -> Failed, branch remains local |
617
- | PR creation fails | Ticket still moves to PR Ready (best-effort) |
652
+ | PR creation fails | Ticket still moves to Testing (best-effort) |
618
653
  | Duplicate poll trigger | Skipped via in-memory lock per ticket ID |
619
654
  | Agent hangs > 30 min | Lock force-released, ticket -> Failed |
620
655
 
@@ -673,7 +708,7 @@ Add to `projects.json` (or re-run `ticket-to-pr init`):
673
708
  - Authenticate: `gh auth login`
674
709
  - Verify: `gh auth status`
675
710
  - The project must have a GitHub `origin` remote
676
- - PR creation is best-effort — the ticket still moves to PR Ready without it
711
+ - PR creation is best-effort — the ticket still moves to Testing without it
677
712
 
678
713
  </details>
679
714
 
package/dist/config.d.ts CHANGED
@@ -6,7 +6,8 @@ export declare const CONFIG: {
6
6
  readonly SCORED: "Scored";
7
7
  readonly EXECUTE: "Execute";
8
8
  readonly IN_PROGRESS: "In Progress";
9
- readonly DONE: "PR Ready";
9
+ readonly TESTING: "Testing";
10
+ readonly DONE: "Done";
10
11
  readonly FAILED: "Failed";
11
12
  };
12
13
  readonly REVIEW_BUDGET_USD: 2;
@@ -88,6 +89,8 @@ export interface TicketDetails extends NotionTicket {
88
89
  bodyBlocks: string;
89
90
  spec?: string;
90
91
  impact?: string;
92
+ ease?: number;
93
+ confidence?: number;
91
94
  }
92
95
  export interface ReviewOutput {
93
96
  easeScore: number;
package/dist/config.js CHANGED
@@ -40,7 +40,8 @@ export const CONFIG = {
40
40
  SCORED: 'Scored',
41
41
  EXECUTE: 'Execute',
42
42
  IN_PROGRESS: 'In Progress',
43
- DONE: 'PR Ready',
43
+ TESTING: 'Testing',
44
+ DONE: 'Done',
44
45
  FAILED: 'Failed',
45
46
  },
46
47
  // Agent budgets
package/dist/index.js CHANGED
@@ -3,9 +3,9 @@ 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, 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';
6
+ import { sleep, clamp, extractJsonFromOutput, shellEscape, 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
- import { fetchTicketsByStatus, fetchTicketDetails, writeReviewResults, writeExecutionResults, moveTicketStatus, writeFailure, addComment, } from './lib/notion.js';
8
+ import { fetchTicketsByStatus, fetchTicketDetails, writeReviewResults, writeExecutionResults, moveTicketStatus, writeFailure, addComment, fetchComments, hasFeedbackMarker, hasTestingMarker, trySetDate, } 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
11
11
  loadEnv(join(CONFIG_DIR, '.env.local'));
@@ -32,6 +32,8 @@ const DRY_RUN = args.includes('--dry-run');
32
32
  const ONCE = args.includes('--once');
33
33
  // -- State --
34
34
  const activeLocks = new Map();
35
+ const feedbackProcessed = new Set();
36
+ const testingNotified = new Set();
35
37
  let shuttingDown = false;
36
38
  let activeAgentCount = 0;
37
39
  // -- Logging --
@@ -53,6 +55,7 @@ const reviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'review.md'), 'u
53
55
  const executePrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'execute.md'), 'utf-8');
54
56
  const diffReviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'diff-review.md'), 'utf-8');
55
57
  const retroPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'retro.md'), 'utf-8');
58
+ const feedbackPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'feedback.md'), 'utf-8');
56
59
  // -- Agent Runner --
57
60
  async function runReviewAgent(ticket) {
58
61
  const projectDir = getProjectDir(ticket.project);
@@ -378,6 +381,176 @@ async function runRetroAgent(projectDir, worktreeDir, baseBranch, ticket, outcom
378
381
  log(DIM, 'RETRO', `Skipped: ${e instanceof Error ? e.message : e}`);
379
382
  }
380
383
  }
384
+ async function postTestChecklist(ticket) {
385
+ log(CYAN, 'TESTING', `Posting test checklist for "${ticket.title}"`);
386
+ try {
387
+ // Cross-session dedup: check if we already posted a testing checklist
388
+ const alreadyPosted = await hasTestingMarker(ticket.id);
389
+ if (alreadyPosted) {
390
+ log(DIM, 'TESTING', `Checklist already posted for "${ticket.title}", skipping`);
391
+ return;
392
+ }
393
+ // Extract acceptance tests from the spec
394
+ const spec = ticket.spec ?? '';
395
+ const description = ticket.description ?? '';
396
+ let checklist = [];
397
+ // Parse acceptance tests from spec (format: "## Acceptance Tests\n- GIVEN/WHEN/THEN...")
398
+ const acceptanceMatch = spec.match(/## Acceptance Tests\n([\s\S]*?)(?:\n##|$)/);
399
+ if (acceptanceMatch) {
400
+ checklist = acceptanceMatch[1]
401
+ .split('\n')
402
+ .filter(line => line.trim().startsWith('-'))
403
+ .map(line => {
404
+ // Convert GIVEN/WHEN/THEN to plain language
405
+ let text = line.replace(/^-\s*/, '').trim();
406
+ text = text
407
+ .replace(/^GIVEN\s+/i, 'Start with: ')
408
+ .replace(/\s*WHEN\s+/i, ' → Do: ')
409
+ .replace(/\s*THEN\s+/i, ' → Expect: ');
410
+ return text;
411
+ });
412
+ }
413
+ // If no acceptance tests, generate a basic checklist from description
414
+ if (checklist.length === 0) {
415
+ checklist = [
416
+ `Verify the change described in the ticket works: "${description.slice(0, 200)}"`,
417
+ 'Check that nothing else looks broken',
418
+ 'Comment on this ticket with what you found',
419
+ ];
420
+ }
421
+ // Build the comment
422
+ const checklistText = checklist.map((item, i) => `${i + 1}. ${item}`).join('\n');
423
+ const comment = [
424
+ `🧪 Ready for Testing`,
425
+ '',
426
+ `This ticket has been deployed. Please test the following:`,
427
+ '',
428
+ checklistText,
429
+ '',
430
+ `When done, comment with what you found and drag to:`,
431
+ `→ "Done" if it works`,
432
+ `→ "Failed" if something's wrong (please describe what happened)`,
433
+ ].join('\n');
434
+ await addComment(ticket.id, comment);
435
+ // Executed At is already set when the ticket lands here — no separate timestamp needed
436
+ log(GREEN, 'TESTING', `Posted checklist for "${ticket.title}" (${checklist.length} items)`);
437
+ }
438
+ catch (e) {
439
+ log(YELLOW, 'TESTING', `Failed to post checklist: ${e instanceof Error ? e.message : e}`);
440
+ }
441
+ }
442
+ async function runFeedbackRetro(ticket, status) {
443
+ const projectDir = getProjectDir(ticket.project);
444
+ if (!projectDir)
445
+ return;
446
+ const statusLabel = status === CONFIG.COLUMNS.FAILED ? 'failed' : 'done';
447
+ log(CYAN, 'FEEDBACK', `Processing feedback for "${ticket.title}" (${statusLabel})`);
448
+ try {
449
+ // Cross-session dedup: check if already processed
450
+ const alreadyProcessed = await hasFeedbackMarker(ticket.id);
451
+ if (alreadyProcessed) {
452
+ log(DIM, 'FEEDBACK', `Already processed, skipping "${ticket.title}"`);
453
+ return;
454
+ }
455
+ // Read human comments from the ticket
456
+ const comments = await fetchComments(ticket.id);
457
+ if (comments.length === 0) {
458
+ log(DIM, 'FEEDBACK', 'No human comments found, skipping');
459
+ // Only mark Done tickets as processed (Failed might get retried with comments later)
460
+ if (status === CONFIG.COLUMNS.DONE) {
461
+ await addComment(ticket.id, '🔄 Feedback processed (no human comments found)');
462
+ }
463
+ return;
464
+ }
465
+ const existingLearnings = readLearnings(projectDir);
466
+ // Build feedback prompt with status context
467
+ const parts = [
468
+ feedbackPrompt,
469
+ '',
470
+ `## Ticket: ${ticket.title}`,
471
+ `## Status: ${status}`,
472
+ '',
473
+ '**Description**:',
474
+ ticket.description,
475
+ '',
476
+ '**Spec**:',
477
+ ticket.spec ?? '(none)',
478
+ '',
479
+ '**Impact/Error info**:',
480
+ ticket.impact ?? '(none)',
481
+ ];
482
+ if (status === CONFIG.COLUMNS.FAILED) {
483
+ parts.push('', '## Context', 'This ticket FAILED. The human comments below describe what went wrong from the user\'s perspective (not just the agent error). Extract learnings about what the AI should do differently.');
484
+ }
485
+ else {
486
+ parts.push('', '## Context', 'This ticket was completed and the human tested the result. Their comments describe what they found.');
487
+ }
488
+ parts.push('', '## Human Feedback', ...comments.map(c => `**${c.author}** (${c.createdTime}):\n> ${c.text}`));
489
+ if (existingLearnings) {
490
+ parts.push('', '## Existing Learnings (do not repeat these)', existingLearnings);
491
+ }
492
+ const prompt = parts.join('\n');
493
+ const messages = query({
494
+ prompt,
495
+ options: {
496
+ model: CONFIG.DIFF_REVIEW_MODEL, // Haiku — cheap and fast
497
+ cwd: projectDir,
498
+ allowedTools: [],
499
+ maxTurns: 3,
500
+ maxBudgetUsd: 0.10,
501
+ permissionMode: 'bypassPermissions',
502
+ allowDangerouslySkipPermissions: true,
503
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
504
+ stderr: () => { },
505
+ },
506
+ });
507
+ let output = '';
508
+ for await (const message of messages) {
509
+ if (message.type === 'assistant') {
510
+ const content = message.message?.content;
511
+ if (Array.isArray(content)) {
512
+ for (const block of content) {
513
+ if (block.type === 'text')
514
+ output = block.text;
515
+ }
516
+ }
517
+ else if (typeof content === 'string') {
518
+ output = content;
519
+ }
520
+ }
521
+ if (message.type === 'result') {
522
+ if ('result' in message && message.result) {
523
+ output = message.result;
524
+ }
525
+ }
526
+ }
527
+ // Save learnings if meaningful
528
+ const trimmed = output.trim();
529
+ if (trimmed && !trimmed.toLowerCase().includes('no new learnings')) {
530
+ const tag = status === CONFIG.COLUMNS.FAILED ? 'Feedback (failed)' : 'Feedback';
531
+ appendLearning(projectDir, `**${tag}: ${ticket.title}**\n${trimmed}`);
532
+ const count = trimmed.split('\n').filter(l => l.startsWith('-') || l.startsWith('*')).length;
533
+ log(GREEN, 'FEEDBACK', `Saved ${count} learnings from human feedback`);
534
+ }
535
+ else {
536
+ log(DIM, 'FEEDBACK', 'No new learnings from feedback');
537
+ }
538
+ // Mark as processed with a summary comment
539
+ const feedbackSummary = comments.map(c => `- ${c.author}: "${c.text.slice(0, 100)}${c.text.length > 100 ? '...' : ''}"`).join('\n');
540
+ const learningNote = trimmed && !trimmed.toLowerCase().includes('no new learnings')
541
+ ? 'Learnings saved to project memory.'
542
+ : 'No new learnings extracted.';
543
+ await addComment(ticket.id, `🔄 Feedback processed\n\nComments analyzed:\n${feedbackSummary}\n\n${learningNote}`);
544
+ if (status === CONFIG.COLUMNS.DONE) {
545
+ await trySetDate(ticket.id, 'Done At');
546
+ }
547
+ log(GREEN, 'FEEDBACK', `Done processing feedback for "${ticket.title}"`);
548
+ }
549
+ catch (e) {
550
+ // Feedback is best-effort
551
+ log(YELLOW, 'FEEDBACK', `Failed: ${e instanceof Error ? e.message : e}`);
552
+ }
553
+ }
381
554
  async function runExecuteAgent(ticket) {
382
555
  const projectDir = getProjectDir(ticket.project);
383
556
  if (!projectDir) {
@@ -564,7 +737,7 @@ async function runExecuteAgent(ticket) {
564
737
  `[View in Notion](https://www.notion.so/${ticket.id.replace(/-/g, '')})`,
565
738
  '',
566
739
  '---',
567
- `Cost: $${cost.toFixed(2)} | Review: Ease ${extractNumber(ticket, 'ease')}/10, Confidence ${extractNumber(ticket, 'confidence')}/10`,
740
+ `Cost: $${cost.toFixed(2)} | Review: Ease ${ticket.ease ?? '?'}/10, Confidence ${ticket.confidence ?? '?'}/10`,
568
741
  ].join('\n');
569
742
  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 });
570
743
  prUrl = prResult.toString().trim();
@@ -577,7 +750,7 @@ async function runExecuteAgent(ticket) {
577
750
  }
578
751
  // Update Notion
579
752
  await writeExecutionResults(ticket.id, { branch: branchName, cost, prUrl });
580
- await moveTicketStatus(ticket.id, CONFIG.COLUMNS.DONE);
753
+ await moveTicketStatus(ticket.id, CONFIG.COLUMNS.TESTING);
581
754
  const duration = Math.round((Date.now() - startTime) / 1000);
582
755
  // Add success audit trail comment
583
756
  const comment = [
@@ -694,20 +867,35 @@ async function poll() {
694
867
  try {
695
868
  // Clear stale locks
696
869
  clearStaleLocks();
697
- // Fetch tickets in Review and Execute columns
698
- const [reviewTickets, executeTickets] = await Promise.all([
870
+ // Fetch tickets in Review, Execute, Testing, Done, and Failed columns
871
+ const [reviewTickets, executeTickets, testingTickets, doneTickets, failedTickets] = await Promise.all([
699
872
  fetchTicketsByStatus(CONFIG.COLUMNS.REVIEW),
700
873
  fetchTicketsByStatus(CONFIG.COLUMNS.EXECUTE),
874
+ fetchTicketsByStatus(CONFIG.COLUMNS.TESTING),
875
+ fetchTicketsByStatus(CONFIG.COLUMNS.DONE),
876
+ fetchTicketsByStatus(CONFIG.COLUMNS.FAILED),
701
877
  ]);
702
878
  const pendingReview = reviewTickets.filter((t) => !activeLocks.has(t.id));
703
879
  const pendingExecute = executeTickets.filter((t) => !activeLocks.has(t.id));
880
+ const pendingTesting = testingTickets.filter((t) => !testingNotified.has(t.id));
881
+ // Feedback candidates: Done tickets + Failed tickets (both can have human comments)
882
+ const pendingFeedback = [
883
+ ...doneTickets.map(t => ({ ...t, feedbackStatus: CONFIG.COLUMNS.DONE })),
884
+ ...failedTickets.map(t => ({ ...t, feedbackStatus: CONFIG.COLUMNS.FAILED })),
885
+ ].filter((t) => !feedbackProcessed.has(t.id));
704
886
  if (pendingReview.length > 0) {
705
887
  log(CYAN, 'POLL', `Found ${pendingReview.length} ticket(s) to review`);
706
888
  }
707
889
  if (pendingExecute.length > 0) {
708
890
  log(MAGENTA, 'POLL', `Found ${pendingExecute.length} ticket(s) to execute`);
709
891
  }
710
- if (pendingReview.length === 0 && pendingExecute.length === 0) {
892
+ if (pendingTesting.length > 0) {
893
+ log(YELLOW, 'POLL', `Found ${pendingTesting.length} ticket(s) needing test checklists`);
894
+ }
895
+ if (pendingFeedback.length > 0) {
896
+ log(GREEN, 'POLL', `Found ${pendingFeedback.length} ticket(s) to check for feedback`);
897
+ }
898
+ if (pendingReview.length === 0 && pendingExecute.length === 0 && pendingTesting.length === 0 && pendingFeedback.length === 0) {
711
899
  log(DIM, 'POLL', 'No tickets to process');
712
900
  }
713
901
  if (DRY_RUN)
@@ -736,6 +924,26 @@ async function poll() {
736
924
  log(RED, 'UNHANDLED', `Unexpected error in ${mode} for "${details.title}": ${err instanceof Error ? err.message : err}`);
737
925
  });
738
926
  }
927
+ // Post test checklists for Testing tickets (lightweight, no AI agent)
928
+ for (const ticket of pendingTesting) {
929
+ if (shuttingDown)
930
+ break;
931
+ testingNotified.add(ticket.id);
932
+ const details = await fetchTicketDetails(ticket.id);
933
+ postTestChecklist(details).catch((err) => {
934
+ log(YELLOW, 'TESTING', `Error posting checklist for "${details.title}": ${err instanceof Error ? err.message : err}`);
935
+ });
936
+ }
937
+ // Process feedback for Done and Failed tickets (lightweight, doesn't count toward concurrency)
938
+ for (const ticket of pendingFeedback) {
939
+ if (shuttingDown)
940
+ break;
941
+ feedbackProcessed.add(ticket.id);
942
+ const details = await fetchTicketDetails(ticket.id);
943
+ runFeedbackRetro(details, ticket.feedbackStatus).catch((err) => {
944
+ log(YELLOW, 'FEEDBACK', `Error processing feedback for "${details.title}": ${err instanceof Error ? err.message : err}`);
945
+ });
946
+ }
739
947
  }
740
948
  catch (error) {
741
949
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -4,6 +4,10 @@ export declare function extractPlainText(richText: RichTextItemResponse[]): stri
4
4
  export declare function extractProjectName(page: PageObjectResponse): string;
5
5
  export declare function pageToTicket(page: PageObjectResponse): NotionTicket;
6
6
  export declare function blockToMarkdown(block: BlockObjectResponse): string;
7
+ /**
8
+ * Set a date property on a page (best-effort — skips silently if property doesn't exist).
9
+ */
10
+ export declare function trySetDate(pageId: string, propertyName: string): Promise<void>;
7
11
  /**
8
12
  * Fetch all tickets with a given status from the Notion database.
9
13
  */
@@ -37,6 +41,17 @@ export declare function writeFailure(pageId: string, error: string): Promise<voi
37
41
  * Used for agent audit trail - does not throw if it fails.
38
42
  */
39
43
  export declare function addComment(pageId: string, text: string): Promise<void>;
44
+ /**
45
+ * Fetch all comments on a Notion page.
46
+ * Returns human comments (filters out bot-authored comments from this integration).
47
+ */
48
+ export declare function fetchComments(pageId: string): Promise<Array<{
49
+ author: string;
50
+ text: string;
51
+ createdTime: string;
52
+ }>>;
53
+ export declare function hasFeedbackMarker(pageId: string): Promise<boolean>;
54
+ export declare function hasTestingMarker(pageId: string): Promise<boolean>;
40
55
  export declare function truncate(str: string, maxLen: number): string;
41
56
  /** Chunk text into Notion rich_text segments (each max 2000 chars). */
42
57
  export declare function chunkRichText(str: string): Array<{
@@ -26,6 +26,10 @@ function extractRichText(page, name) {
26
26
  const prop = getProperty(page, name);
27
27
  return prop?.rich_text ? extractPlainText(prop.rich_text) : '';
28
28
  }
29
+ function extractNumber(page, name) {
30
+ const prop = getProperty(page, name);
31
+ return prop?.number ?? undefined;
32
+ }
29
33
  function extractSelect(page, name) {
30
34
  const prop = getProperty(page, name);
31
35
  return prop?.select?.name ?? '';
@@ -90,6 +94,26 @@ export function blockToMarkdown(block) {
90
94
  return text;
91
95
  }
92
96
  }
97
+ function nowISO() {
98
+ return new Date().toISOString();
99
+ }
100
+ function dateProperty(iso) {
101
+ return { date: { start: iso } };
102
+ }
103
+ /**
104
+ * Set a date property on a page (best-effort — skips silently if property doesn't exist).
105
+ */
106
+ export async function trySetDate(pageId, propertyName) {
107
+ try {
108
+ await notion().pages.update({
109
+ page_id: pageId,
110
+ properties: { [propertyName]: dateProperty(nowISO()) },
111
+ });
112
+ }
113
+ catch {
114
+ // Property might not exist yet — skip silently
115
+ }
116
+ }
93
117
  // -- Exported Functions --
94
118
  /**
95
119
  * Fetch all tickets with a given status from the Notion database.
@@ -123,6 +147,8 @@ export async function fetchTicketDetails(pageId) {
123
147
  bodyBlocks,
124
148
  spec: extractRichText(page, 'Spec') || undefined,
125
149
  impact: extractRichText(page, 'Impact') || undefined,
150
+ ease: extractNumber(page, 'Ease'),
151
+ confidence: extractNumber(page, 'Confidence'),
126
152
  };
127
153
  }
128
154
  /**
@@ -144,15 +170,21 @@ export async function writeReviewResults(pageId, results) {
144
170
  Impact: {
145
171
  rich_text: chunkRichText(`${results.impactReport}\n\nFiles: ${results.affectedFiles.join(', ')}${results.risks ? `\n\nRisks: ${results.risks}` : ''}`),
146
172
  },
173
+ 'Reviewed At': dateProperty(nowISO()),
147
174
  };
148
175
  try {
149
176
  await notion().pages.update({ page_id: pageId, properties });
150
177
  }
151
178
  catch (e) {
152
- // If Confidence property doesn't exist yet, retry without it
153
179
  const errMsg = String(e);
180
+ // If a property doesn't exist yet, retry without it
154
181
  if (errMsg.includes('Confidence')) {
155
182
  delete properties.Confidence;
183
+ }
184
+ if (errMsg.includes('Reviewed At')) {
185
+ delete properties['Reviewed At'];
186
+ }
187
+ if (errMsg.includes('Confidence') || errMsg.includes('Reviewed At')) {
156
188
  await notion().pages.update({ page_id: pageId, properties });
157
189
  }
158
190
  else {
@@ -172,13 +204,26 @@ export async function writeExecutionResults(pageId, results) {
172
204
  Cost: {
173
205
  rich_text: [{ text: { content: `$${(Math.round(results.cost * 100) / 100).toFixed(2)}` } }],
174
206
  },
207
+ 'Executed At': dateProperty(nowISO()),
175
208
  };
176
209
  if (results.prUrl) {
177
210
  properties['PR URL'] = {
178
211
  url: results.prUrl,
179
212
  };
180
213
  }
181
- await notion().pages.update({ page_id: pageId, properties });
214
+ try {
215
+ await notion().pages.update({ page_id: pageId, properties });
216
+ }
217
+ catch (e) {
218
+ const errMsg = String(e);
219
+ if (errMsg.includes('Executed At')) {
220
+ delete properties['Executed At'];
221
+ await notion().pages.update({ page_id: pageId, properties });
222
+ }
223
+ else {
224
+ throw e;
225
+ }
226
+ }
182
227
  }
183
228
  /**
184
229
  * Move a ticket to a new status column.
@@ -195,15 +240,27 @@ export async function moveTicketStatus(pageId, newStatus) {
195
240
  * Write error details and move ticket to Failed.
196
241
  */
197
242
  export async function writeFailure(pageId, error) {
198
- await notion().pages.update({
199
- page_id: pageId,
200
- properties: {
201
- Status: { status: { name: CONFIG.COLUMNS.FAILED } },
202
- Impact: {
203
- rich_text: chunkRichText(`ERROR: ${error}`),
204
- },
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
+ const properties = {
245
+ Status: { status: { name: CONFIG.COLUMNS.FAILED } },
246
+ Impact: {
247
+ rich_text: chunkRichText(`ERROR: ${error}`),
205
248
  },
206
- });
249
+ 'Failed At': dateProperty(nowISO()),
250
+ };
251
+ try {
252
+ await notion().pages.update({ page_id: pageId, properties });
253
+ }
254
+ catch (e) {
255
+ const errMsg = String(e);
256
+ if (errMsg.includes('Failed At')) {
257
+ delete properties['Failed At'];
258
+ await notion().pages.update({ page_id: pageId, properties });
259
+ }
260
+ else {
261
+ throw e;
262
+ }
263
+ }
207
264
  }
208
265
  /**
209
266
  * Add a comment to a Notion page (best-effort).
@@ -221,6 +278,66 @@ export async function addComment(pageId, text) {
221
278
  console.warn(`[NOTION] Failed to add comment to ${pageId}:`, e);
222
279
  }
223
280
  }
281
+ /**
282
+ * Fetch all comments on a Notion page.
283
+ * Returns human comments (filters out bot-authored comments from this integration).
284
+ */
285
+ export async function fetchComments(pageId) {
286
+ try {
287
+ const response = await notion().comments.list({ block_id: pageId });
288
+ const comments = [];
289
+ for (const comment of response.results) {
290
+ // Skip bot-authored comments (our own audit trail)
291
+ const createdBy = comment.created_by;
292
+ if (createdBy?.type === 'bot')
293
+ continue;
294
+ const text = 'rich_text' in comment
295
+ ? extractPlainText(comment.rich_text)
296
+ : '';
297
+ if (!text.trim())
298
+ continue;
299
+ comments.push({
300
+ author: createdBy?.name || 'Unknown',
301
+ text: text.trim(),
302
+ createdTime: comment.created_time,
303
+ });
304
+ }
305
+ return comments;
306
+ }
307
+ catch (e) {
308
+ console.warn(`[NOTION] Failed to fetch comments for ${pageId}:`, e);
309
+ return [];
310
+ }
311
+ }
312
+ /**
313
+ * Check if a ticket already has a specific bot marker comment.
314
+ * Used for cross-session deduplication.
315
+ */
316
+ async function hasBotMarker(pageId, marker) {
317
+ try {
318
+ const response = await notion().comments.list({ block_id: pageId });
319
+ for (const comment of response.results) {
320
+ const createdBy = comment.created_by;
321
+ if (createdBy?.type !== 'bot')
322
+ continue;
323
+ const text = 'rich_text' in comment
324
+ ? extractPlainText(comment.rich_text)
325
+ : '';
326
+ if (text.includes(marker))
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ }
335
+ export function hasFeedbackMarker(pageId) {
336
+ return hasBotMarker(pageId, 'Feedback processed');
337
+ }
338
+ export function hasTestingMarker(pageId) {
339
+ return hasBotMarker(pageId, 'Ready for Testing');
340
+ }
224
341
  export function truncate(str, maxLen) {
225
342
  if (str.length <= maxLen)
226
343
  return str;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticket-to-pr",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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,34 @@
1
+ You are analyzing human feedback on AI-generated code changes.
2
+
3
+ ## Your Task
4
+ A human (PM, designer, developer, QA tester, or founder) reviewed the result of an AI agent's work and left comments on the Notion ticket. Extract actionable learnings from their feedback 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 (ticket status: Done)
9
+ - **What the human liked**: patterns, code style, thoroughness that should be repeated
10
+ - **What worked well in production**: successful deployment, no regressions
11
+ - **What could be better**: minor issues that didn't block approval but should be improved next time
12
+ - **Preferences revealed**: the human's style preferences, naming conventions, UX expectations
13
+
14
+ ### On Failure (ticket status: Failed)
15
+ - **What broke from the user's perspective**: not the agent error message, but what the human actually experienced
16
+ - **Root cause insight**: the human often knows WHY something failed better than the error log
17
+ - **What the human expected vs what happened**: gap between intent and implementation
18
+ - **Scope issues**: was the change too big, too small, missing context, or in the wrong place?
19
+
20
+ ### Implicit Signals
21
+ - If the human just says "looks good" or "works" — not much to learn
22
+ - If the human gives detailed feedback — they care about quality, extract everything
23
+ - If multiple people comment — note which role cares about what (PM vs dev vs designer)
24
+
25
+ ## Output Rules
26
+ - Write 1-4 bullet points. Each must be a specific, actionable lesson from the human's perspective.
27
+ - Start each bullet with a category tag: `[feedback]`, `[preference]`, `[bug]`, or `[quality]`
28
+ - Translate vague human feedback into specific technical guidance for the AI agent:
29
+ - "This doesn't look right" on a UI change → `[preference] Stakeholders in this project prefer X style over Y`
30
+ - "It broke the login" → `[bug] Changes to auth-related files can break the login flow — always test auth after touching these files`
31
+ - "Wrong approach" → `[feedback] For this type of change, the preferred pattern is X (not Y)`
32
+ - If the feedback is just "looks good", "approved", "works", or similar with no specific lessons, write: `No new learnings.`
33
+ - Don't repeat lessons that already exist in the project learnings.
34
+ - Be specific to THIS project. Generic advice is useless.