ticket-to-pr 1.0.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.
@@ -0,0 +1,235 @@
1
+ import { Client } from '@notionhq/client';
2
+ import { CONFIG } from '../config.js';
3
+ let _notion = null;
4
+ function notion() {
5
+ if (!_notion) {
6
+ _notion = new Client({ auth: process.env.NOTION_TOKEN });
7
+ }
8
+ return _notion;
9
+ }
10
+ function databaseId() {
11
+ return process.env.NOTION_DATABASE_ID;
12
+ }
13
+ // -- Helpers (exported for testing) --
14
+ export function extractPlainText(richText) {
15
+ return richText.map((t) => t.plain_text).join('');
16
+ }
17
+ function getProperty(page, name) {
18
+ return page.properties[name];
19
+ }
20
+ function extractTitle(page) {
21
+ // Try 'Name' first (Notion default), then 'Title'
22
+ const prop = (getProperty(page, 'Name') ?? getProperty(page, 'Title'));
23
+ return prop?.title ? extractPlainText(prop.title) : '';
24
+ }
25
+ function extractRichText(page, name) {
26
+ const prop = getProperty(page, name);
27
+ return prop?.rich_text ? extractPlainText(prop.rich_text) : '';
28
+ }
29
+ function extractSelect(page, name) {
30
+ const prop = getProperty(page, name);
31
+ return prop?.select?.name ?? '';
32
+ }
33
+ function extractStatus(page) {
34
+ const prop = getProperty(page, 'Status');
35
+ return prop?.status?.name ?? '';
36
+ }
37
+ export function extractProjectName(page) {
38
+ // Support both Select and Rich Text types for Project
39
+ const prop = getProperty(page, 'Project');
40
+ if (!prop)
41
+ return '';
42
+ if (prop.type === 'select') {
43
+ const sel = prop.select;
44
+ return sel?.name ?? '';
45
+ }
46
+ if (prop.type === 'rich_text') {
47
+ const rt = prop.rich_text;
48
+ return rt ? extractPlainText(rt) : '';
49
+ }
50
+ return '';
51
+ }
52
+ export function pageToTicket(page) {
53
+ return {
54
+ id: page.id,
55
+ title: extractTitle(page),
56
+ project: extractProjectName(page),
57
+ status: extractStatus(page),
58
+ };
59
+ }
60
+ export function blockToMarkdown(block) {
61
+ const type = block.type;
62
+ const data = block[type];
63
+ const text = data?.rich_text ? extractPlainText(data.rich_text) : '';
64
+ switch (type) {
65
+ case 'paragraph':
66
+ return text;
67
+ case 'heading_1':
68
+ return `# ${text}`;
69
+ case 'heading_2':
70
+ return `## ${text}`;
71
+ case 'heading_3':
72
+ return `### ${text}`;
73
+ case 'bulleted_list_item':
74
+ return `- ${text}`;
75
+ case 'numbered_list_item':
76
+ return `1. ${text}`;
77
+ case 'to_do': {
78
+ const todo = block[type];
79
+ return `- [${todo?.checked ? 'x' : ' '}] ${text}`;
80
+ }
81
+ case 'code': {
82
+ const code = block[type];
83
+ return `\`\`\`${code?.language ?? ''}\n${text}\n\`\`\``;
84
+ }
85
+ case 'quote':
86
+ return `> ${text}`;
87
+ case 'divider':
88
+ return '---';
89
+ default:
90
+ return text;
91
+ }
92
+ }
93
+ // -- Exported Functions --
94
+ /**
95
+ * Fetch all tickets with a given status from the Notion database.
96
+ */
97
+ export async function fetchTicketsByStatus(status) {
98
+ const response = await notion().databases.query({
99
+ database_id: databaseId(),
100
+ filter: {
101
+ property: 'Status',
102
+ status: { equals: status },
103
+ },
104
+ });
105
+ return response.results
106
+ .filter((r) => 'properties' in r)
107
+ .map(pageToTicket);
108
+ }
109
+ /**
110
+ * Read full ticket details including page body blocks.
111
+ */
112
+ export async function fetchTicketDetails(pageId) {
113
+ const [page, blocksResponse] = await Promise.all([
114
+ notion().pages.retrieve({ page_id: pageId }),
115
+ notion().blocks.children.list({ block_id: pageId, page_size: 100 }),
116
+ ]);
117
+ const blocks = blocksResponse.results.filter((b) => 'type' in b);
118
+ const bodyBlocks = blocks.map(blockToMarkdown).filter(Boolean).join('\n\n');
119
+ const ticket = pageToTicket(page);
120
+ return {
121
+ ...ticket,
122
+ description: extractRichText(page, 'Description'),
123
+ bodyBlocks,
124
+ spec: extractRichText(page, 'Spec') || undefined,
125
+ impact: extractRichText(page, 'Impact') || undefined,
126
+ };
127
+ }
128
+ /**
129
+ * Write review results back to the ticket properties.
130
+ */
131
+ export async function writeReviewResults(pageId, results) {
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ const properties = {
134
+ Ease: { number: results.easeScore },
135
+ Confidence: { number: results.confidenceScore },
136
+ Spec: {
137
+ rich_text: chunkRichText(results.spec),
138
+ },
139
+ Impact: {
140
+ rich_text: chunkRichText(`${results.impactReport}\n\nFiles: ${results.affectedFiles.join(', ')}${results.risks ? `\n\nRisks: ${results.risks}` : ''}`),
141
+ },
142
+ };
143
+ try {
144
+ await notion().pages.update({ page_id: pageId, properties });
145
+ }
146
+ catch (e) {
147
+ // If Confidence property doesn't exist yet, retry without it
148
+ const errMsg = String(e);
149
+ if (errMsg.includes('Confidence')) {
150
+ delete properties.Confidence;
151
+ await notion().pages.update({ page_id: pageId, properties });
152
+ }
153
+ else {
154
+ throw e;
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Write execution results back to the ticket.
160
+ */
161
+ export async function writeExecutionResults(pageId, results) {
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const properties = {
164
+ Branch: {
165
+ rich_text: [{ text: { content: results.branch } }],
166
+ },
167
+ Cost: {
168
+ rich_text: [{ text: { content: `$${(Math.round(results.cost * 100) / 100).toFixed(2)}` } }],
169
+ },
170
+ };
171
+ if (results.prUrl) {
172
+ properties['PR URL'] = {
173
+ url: results.prUrl,
174
+ };
175
+ }
176
+ await notion().pages.update({ page_id: pageId, properties });
177
+ }
178
+ /**
179
+ * Move a ticket to a new status column.
180
+ */
181
+ export async function moveTicketStatus(pageId, newStatus) {
182
+ await notion().pages.update({
183
+ page_id: pageId,
184
+ properties: {
185
+ Status: { status: { name: newStatus } },
186
+ },
187
+ });
188
+ }
189
+ /**
190
+ * Write error details and move ticket to Failed.
191
+ */
192
+ export async function writeFailure(pageId, error) {
193
+ await notion().pages.update({
194
+ page_id: pageId,
195
+ properties: {
196
+ Status: { status: { name: CONFIG.COLUMNS.FAILED } },
197
+ Impact: {
198
+ rich_text: chunkRichText(`ERROR: ${error}`),
199
+ },
200
+ },
201
+ });
202
+ }
203
+ /**
204
+ * Add a comment to a Notion page (best-effort).
205
+ * Used for agent audit trail - does not throw if it fails.
206
+ */
207
+ export async function addComment(pageId, text) {
208
+ try {
209
+ await notion().comments.create({
210
+ parent: { page_id: pageId },
211
+ rich_text: [{ text: { content: text } }],
212
+ });
213
+ }
214
+ catch (e) {
215
+ // Best-effort: log but don't throw
216
+ console.warn(`[NOTION] Failed to add comment to ${pageId}:`, e);
217
+ }
218
+ }
219
+ export function truncate(str, maxLen) {
220
+ if (str.length <= maxLen)
221
+ return str;
222
+ return str.slice(0, maxLen - 3) + '...';
223
+ }
224
+ /** Chunk text into Notion rich_text segments (each max 2000 chars). */
225
+ export function chunkRichText(str) {
226
+ const LIMIT = 2000;
227
+ if (str.length <= LIMIT) {
228
+ return [{ text: { content: str } }];
229
+ }
230
+ const chunks = [];
231
+ for (let i = 0; i < str.length; i += LIMIT) {
232
+ chunks.push({ text: { content: str.slice(i, i + LIMIT) } });
233
+ }
234
+ return chunks;
235
+ }
@@ -0,0 +1,4 @@
1
+ /** Package root — for bundled assets like prompts/ */
2
+ export declare const PACKAGE_ROOT: string;
3
+ /** User's working directory — for config files (.env.local, projects.json) */
4
+ export declare const CONFIG_DIR: string;
@@ -0,0 +1,23 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ /**
6
+ * Root of the package installation — used for bundled assets (prompts/).
7
+ * Walks up from the current file until it finds package.json.
8
+ */
9
+ function findPackageRoot() {
10
+ let dir = __dirname;
11
+ while (true) {
12
+ if (existsSync(join(dir, 'package.json')))
13
+ return dir;
14
+ const parent = dirname(dir);
15
+ if (parent === dir)
16
+ return __dirname; // filesystem root, shouldn't happen
17
+ dir = parent;
18
+ }
19
+ }
20
+ /** Package root — for bundled assets like prompts/ */
21
+ export const PACKAGE_ROOT = findPackageRoot();
22
+ /** User's working directory — for config files (.env.local, projects.json) */
23
+ export const CONFIG_DIR = process.cwd();
@@ -0,0 +1,6 @@
1
+ export declare function getProjectDir(name: string): string | undefined;
2
+ export declare function getProjectNames(): string[];
3
+ export declare function getBuildCommand(name: string): string | undefined;
4
+ export declare function getAllProjects(): Record<string, string>;
5
+ /** Reset the in-memory cache (for tests). */
6
+ export declare function _resetCache(): void;
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { CONFIG_DIR } from './paths.js';
4
+ const PROJECTS_PATH = join(CONFIG_DIR, 'projects.json');
5
+ let cache = null;
6
+ function load() {
7
+ if (cache)
8
+ return cache;
9
+ try {
10
+ const content = readFileSync(PROJECTS_PATH, 'utf-8');
11
+ cache = JSON.parse(content);
12
+ }
13
+ catch {
14
+ cache = { projects: {} };
15
+ }
16
+ return cache;
17
+ }
18
+ export function getProjectDir(name) {
19
+ return load().projects[name]?.directory;
20
+ }
21
+ export function getProjectNames() {
22
+ return Object.keys(load().projects);
23
+ }
24
+ export function getBuildCommand(name) {
25
+ return load().projects[name]?.buildCommand;
26
+ }
27
+ export function getAllProjects() {
28
+ const data = load();
29
+ const result = {};
30
+ for (const [name, entry] of Object.entries(data.projects)) {
31
+ result[name] = entry.directory;
32
+ }
33
+ return result;
34
+ }
35
+ /** Reset the in-memory cache (for tests). */
36
+ export function _resetCache() {
37
+ cache = null;
38
+ }
@@ -0,0 +1,21 @@
1
+ export declare function sleep(ms: number): Promise<void>;
2
+ export declare function clamp(val: number, min: number, max: number): number;
3
+ export declare function extractJsonFromOutput(text: string): Record<string, unknown> | null;
4
+ export declare function shellEscape(str: string): string;
5
+ export declare function extractNumber(ticket: {
6
+ impact?: string;
7
+ }, field: string): string;
8
+ export declare function loadEnv(filepath: string): void;
9
+ export declare function mask(str: string): string;
10
+ export declare function writeEnvFile(filepath: string, updates: Record<string, string>): void;
11
+ export declare function updateProjectsFile(filepath: string, projects: Array<{
12
+ name: string;
13
+ dir: string;
14
+ buildCmd?: string;
15
+ }>): void;
16
+ export declare function getDefaultBranch(projectDir: string): string;
17
+ /** Reset the default branch cache (for tests). */
18
+ export declare function _resetDefaultBranchCache(): void;
19
+ export declare function ensureWorktreesIgnored(projectDir: string): void;
20
+ export declare function createWorktree(projectDir: string, branchName: string, worktreeDir: string): void;
21
+ export declare function removeWorktree(projectDir: string, worktreeDir: string): void;
@@ -0,0 +1,254 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { execSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { rmSync, mkdirSync } from 'node:fs';
5
+ // -- Pure utilities --
6
+ export function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+ export function clamp(val, min, max) {
10
+ return Math.max(min, Math.min(max, val));
11
+ }
12
+ export function extractJsonFromOutput(text) {
13
+ // Find the last JSON code block
14
+ const codeBlockRegex = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
15
+ let lastMatch = null;
16
+ let match;
17
+ while ((match = codeBlockRegex.exec(text)) !== null) {
18
+ lastMatch = match[1];
19
+ }
20
+ // Try parsing the last code block first
21
+ if (lastMatch) {
22
+ try {
23
+ return JSON.parse(lastMatch);
24
+ }
25
+ catch {
26
+ // Fall through
27
+ }
28
+ }
29
+ // Try parsing the entire text as JSON (in case no code blocks)
30
+ try {
31
+ return JSON.parse(text);
32
+ }
33
+ catch {
34
+ // Fall through
35
+ }
36
+ // Try finding a raw JSON object
37
+ const jsonRegex = /\{[\s\S]*"easeScore"[\s\S]*\}/;
38
+ const rawMatch = text.match(jsonRegex);
39
+ if (rawMatch) {
40
+ try {
41
+ return JSON.parse(rawMatch[0]);
42
+ }
43
+ catch {
44
+ // Give up
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ export function shellEscape(str) {
50
+ return `'${str.replace(/'/g, "'\\''")}'`;
51
+ }
52
+ export function extractNumber(ticket, field) {
53
+ const text = ticket.impact ?? '';
54
+ if (field === 'ease') {
55
+ const match = text.match(/Ease[:\s]*(\d+)/i);
56
+ return match ? match[1] : '?';
57
+ }
58
+ if (field === 'confidence') {
59
+ const match = text.match(/Confidence[:\s]*(\d+)/i);
60
+ return match ? match[1] : '?';
61
+ }
62
+ return '?';
63
+ }
64
+ export function loadEnv(filepath) {
65
+ try {
66
+ const content = readFileSync(filepath, 'utf-8');
67
+ for (const line of content.split('\n')) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed || trimmed.startsWith('#'))
70
+ continue;
71
+ const eqIndex = trimmed.indexOf('=');
72
+ if (eqIndex === -1)
73
+ continue;
74
+ const key = trimmed.slice(0, eqIndex).trim();
75
+ const value = trimmed.slice(eqIndex + 1).trim();
76
+ if (!process.env[key]) {
77
+ process.env[key] = value;
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // .env.local doesn't exist, that's fine if env vars are set elsewhere
83
+ }
84
+ }
85
+ export function mask(str) {
86
+ if (str.length <= 8)
87
+ return '****';
88
+ return str.slice(0, 4) + '...' + str.slice(-4);
89
+ }
90
+ export function writeEnvFile(filepath, updates) {
91
+ let lines = [];
92
+ try {
93
+ lines = readFileSync(filepath, 'utf-8').split('\n');
94
+ }
95
+ catch {
96
+ // File doesn't exist yet
97
+ }
98
+ const remaining = { ...updates };
99
+ // Update existing keys in place
100
+ const updatedLines = lines.map((line) => {
101
+ const trimmed = line.trim();
102
+ if (!trimmed || trimmed.startsWith('#'))
103
+ return line;
104
+ const eqIndex = trimmed.indexOf('=');
105
+ if (eqIndex === -1)
106
+ return line;
107
+ const key = trimmed.slice(0, eqIndex).trim();
108
+ if (key in remaining) {
109
+ const val = remaining[key];
110
+ delete remaining[key];
111
+ return `${key}=${val}`;
112
+ }
113
+ return line;
114
+ });
115
+ // Append any new keys
116
+ for (const [key, val] of Object.entries(remaining)) {
117
+ updatedLines.push(`${key}=${val}`);
118
+ }
119
+ writeFileSync(filepath, updatedLines.join('\n'), { mode: 0o600 });
120
+ }
121
+ export function updateProjectsFile(filepath, projects) {
122
+ let data = {
123
+ projects: {},
124
+ };
125
+ try {
126
+ const content = readFileSync(filepath, 'utf-8');
127
+ data = JSON.parse(content);
128
+ }
129
+ catch {
130
+ // File doesn't exist or invalid JSON — start fresh
131
+ }
132
+ for (const proj of projects) {
133
+ data.projects[proj.name] = {
134
+ directory: proj.dir,
135
+ ...(proj.buildCmd ? { buildCommand: proj.buildCmd } : {}),
136
+ };
137
+ }
138
+ writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n');
139
+ }
140
+ // -- Default branch detection --
141
+ const defaultBranchCache = new Map();
142
+ export function getDefaultBranch(projectDir) {
143
+ const cached = defaultBranchCache.get(projectDir);
144
+ if (cached)
145
+ return cached;
146
+ let branch = 'main'; // ultimate fallback
147
+ // Try reading the remote HEAD symbolic ref
148
+ try {
149
+ const ref = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
150
+ cwd: projectDir,
151
+ stdio: 'pipe',
152
+ }).toString().trim();
153
+ // refs/remotes/origin/main → main
154
+ const parsed = ref.replace('refs/remotes/origin/', '');
155
+ if (parsed)
156
+ branch = parsed;
157
+ }
158
+ catch {
159
+ // Remote HEAD not set — try common branch names
160
+ try {
161
+ execSync('git rev-parse --verify main', { cwd: projectDir, stdio: 'pipe' });
162
+ branch = 'main';
163
+ }
164
+ catch {
165
+ try {
166
+ execSync('git rev-parse --verify master', { cwd: projectDir, stdio: 'pipe' });
167
+ branch = 'master';
168
+ }
169
+ catch {
170
+ // Give up, default to 'main'
171
+ }
172
+ }
173
+ }
174
+ defaultBranchCache.set(projectDir, branch);
175
+ return branch;
176
+ }
177
+ /** Reset the default branch cache (for tests). */
178
+ export function _resetDefaultBranchCache() {
179
+ defaultBranchCache.clear();
180
+ }
181
+ // -- Git worktree helpers --
182
+ export function ensureWorktreesIgnored(projectDir) {
183
+ const gitignorePath = join(projectDir, '.gitignore');
184
+ try {
185
+ const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
186
+ const lines = content.split('\n');
187
+ if (!lines.some((line) => line.trim() === '.worktrees' || line.trim() === '.worktrees/')) {
188
+ const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
189
+ writeFileSync(gitignorePath, content + separator + '.worktrees/\n');
190
+ }
191
+ }
192
+ catch {
193
+ // Best effort — don't block worktree creation over gitignore
194
+ }
195
+ }
196
+ export function createWorktree(projectDir, branchName, worktreeDir) {
197
+ mkdirSync(join(projectDir, '.worktrees'), { recursive: true });
198
+ ensureWorktreesIgnored(projectDir);
199
+ // Clean up stale worktree if it exists from a crashed run
200
+ if (existsSync(worktreeDir)) {
201
+ try {
202
+ execSync(`git worktree remove ${shellEscape(worktreeDir)} --force`, {
203
+ cwd: projectDir,
204
+ stdio: 'pipe',
205
+ });
206
+ }
207
+ catch {
208
+ rmSync(worktreeDir, { recursive: true, force: true });
209
+ execSync('git worktree prune', { cwd: projectDir, stdio: 'pipe' });
210
+ }
211
+ }
212
+ // Try creating with a new branch first
213
+ try {
214
+ execSync(`git worktree add ${shellEscape(worktreeDir)} -b ${shellEscape(branchName)}`, {
215
+ cwd: projectDir,
216
+ stdio: 'pipe',
217
+ });
218
+ }
219
+ catch {
220
+ // Branch might already exist (retry scenario) — attach to existing branch
221
+ try {
222
+ execSync(`git worktree add ${shellEscape(worktreeDir)} ${shellEscape(branchName)}`, {
223
+ cwd: projectDir,
224
+ stdio: 'pipe',
225
+ });
226
+ }
227
+ catch (e) {
228
+ throw new Error(`Failed to create worktree for branch ${branchName}: ${e}`);
229
+ }
230
+ }
231
+ }
232
+ export function removeWorktree(projectDir, worktreeDir) {
233
+ try {
234
+ execSync(`git worktree remove ${shellEscape(worktreeDir)} --force`, {
235
+ cwd: projectDir,
236
+ stdio: 'pipe',
237
+ });
238
+ }
239
+ catch {
240
+ // Fallback: manual cleanup
241
+ try {
242
+ rmSync(worktreeDir, { recursive: true, force: true });
243
+ }
244
+ catch {
245
+ // Best effort
246
+ }
247
+ try {
248
+ execSync('git worktree prune', { cwd: projectDir, stdio: 'pipe' });
249
+ }
250
+ catch {
251
+ // Best effort
252
+ }
253
+ }
254
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "ticket-to-pr",
3
+ "version": "1.0.0",
4
+ "description": "Drag a Notion ticket, get a pull request. AI-powered dev automation.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ticket-to-pr": "bin/ticket-to-pr.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "bin",
12
+ "prompts",
13
+ "projects.example.json",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "prepublishOnly": "npm run test && npm run build",
22
+ "start": "tsx index.ts",
23
+ "dry-run": "tsx index.ts --dry-run",
24
+ "once": "tsx index.ts --once",
25
+ "init": "tsx index.ts init",
26
+ "doctor": "tsx index.ts doctor",
27
+ "test": "vitest run"
28
+ },
29
+ "dependencies": {
30
+ "@anthropic-ai/claude-agent-sdk": "^0.2.0",
31
+ "@notionhq/client": "^2.2.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.7.0",
35
+ "tsx": "^4.19.0",
36
+ "@types/node": "^22.0.0",
37
+ "vitest": "^3.0.0"
38
+ }
39
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "projects": {
3
+ "MyProject": {
4
+ "directory": "/absolute/path/to/project",
5
+ "buildCommand": "npm run build"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,19 @@
1
+ You are a senior engineer implementing a ticket.
2
+
3
+ ## Your Task
4
+ You have been given a ticket with an implementation spec. Follow the spec and implement the changes.
5
+
6
+ ## Rules
7
+ 1. You are on a feature branch in a git worktree. Do NOT create or switch branches.
8
+ 2. Follow the project's existing patterns and conventions.
9
+ 3. The project's CLAUDE.md contains critical rules — read and follow them.
10
+ 4. Make atomic commits with clear messages as you work.
11
+ 5. DO NOT run `git push`. TicketToPR handles pushing after validation.
12
+ 6. DO NOT run destructive commands (rm -rf, drop tables, reset --hard, etc.).
13
+ 7. DO NOT run `npx prisma db push` or any database migration commands.
14
+ 8. If the spec is unclear, implement the most conservative interpretation.
15
+ 9. Run existing tests if available, but do not add new test files unless the spec explicitly requires it.
16
+ 10. Do not modify files outside the scope of the spec.
17
+
18
+ ## When Done
19
+ Commit all changes with a final commit message summarizing what was done. The commit message should reference the ticket title.