jettypod 4.4.9 → 4.4.11
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/apps/dashboard/README.md +36 -0
- package/apps/dashboard/app/favicon.ico +0 -0
- package/apps/dashboard/app/globals.css +122 -0
- package/apps/dashboard/app/layout.tsx +34 -0
- package/apps/dashboard/app/page.tsx +25 -0
- package/apps/dashboard/app/work/[id]/page.tsx +193 -0
- package/apps/dashboard/components/KanbanBoard.tsx +201 -0
- package/apps/dashboard/components/WorkItemTree.tsx +116 -0
- package/apps/dashboard/components.json +22 -0
- package/apps/dashboard/eslint.config.mjs +18 -0
- package/apps/dashboard/lib/db.ts +270 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.ts +7 -0
- package/apps/dashboard/package.json +33 -0
- package/apps/dashboard/postcss.config.mjs +7 -0
- package/apps/dashboard/public/file.svg +1 -0
- package/apps/dashboard/public/globe.svg +1 -0
- package/apps/dashboard/public/next.svg +1 -0
- package/apps/dashboard/public/vercel.svg +1 -0
- package/apps/dashboard/public/window.svg +1 -0
- package/apps/dashboard/tsconfig.json +34 -0
- package/claude-hooks/enforce-skill-activation.js +225 -0
- package/jettypod.js +53 -0
- package/lib/current-work.js +10 -18
- package/lib/migrations/016-workflow-checkpoints-table.js +70 -0
- package/lib/migrations/017-backfill-epic-id.js +54 -0
- package/lib/planning-status.js +68 -0
- package/lib/workflow-checkpoint.js +204 -0
- package/package.json +7 -2
- package/skills-templates/chore-mode/SKILL.md +3 -0
- package/skills-templates/epic-planning/SKILL.md +225 -154
- package/skills-templates/feature-planning/SKILL.md +172 -87
- package/skills-templates/speed-mode/SKILL.md +161 -338
- package/skills-templates/stable-mode/SKILL.md +8 -2
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse Hook
|
|
4
|
+
*
|
|
5
|
+
* Prevents bypassing the feature-planning skill by detecting direct chore
|
|
6
|
+
* creation under unplanned features.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// Read hook input from stdin
|
|
13
|
+
let input = '';
|
|
14
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
15
|
+
process.stdin.on('end', () => {
|
|
16
|
+
try {
|
|
17
|
+
const hookInput = JSON.parse(input);
|
|
18
|
+
const { tool_name, tool_input, cwd } = hookInput;
|
|
19
|
+
|
|
20
|
+
// Only check Bash commands
|
|
21
|
+
if (tool_name !== 'Bash') {
|
|
22
|
+
allow();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const command = tool_input.command || '';
|
|
27
|
+
|
|
28
|
+
// Check for chore creation under a parent feature
|
|
29
|
+
const parentId = extractParentFeatureId(command);
|
|
30
|
+
if (!parentId) {
|
|
31
|
+
// Not a chore creation with --parent, allow
|
|
32
|
+
allow();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if feature-planning skill is active
|
|
37
|
+
// Look for skill markers in the session or transcript
|
|
38
|
+
if (isSkillActive(hookInput)) {
|
|
39
|
+
allow();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Query database to check if feature is planned
|
|
44
|
+
checkFeaturePlanned(cwd, parentId)
|
|
45
|
+
.then(isPlanned => {
|
|
46
|
+
if (isPlanned) {
|
|
47
|
+
allow();
|
|
48
|
+
} else {
|
|
49
|
+
deny(
|
|
50
|
+
'Cannot create chores under unplanned feature',
|
|
51
|
+
'Invoke the feature-planning skill first to plan this feature before creating chores.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.catch(err => {
|
|
56
|
+
// On error, allow (fail open) but log
|
|
57
|
+
console.error('Hook error:', err.message);
|
|
58
|
+
allow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// If we can't parse input, allow the action (fail open)
|
|
63
|
+
console.error('Hook error:', err.message);
|
|
64
|
+
allow();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract parent feature ID from chore creation command
|
|
70
|
+
* @param {string} command - The bash command
|
|
71
|
+
* @returns {number|null} The parent feature ID or null
|
|
72
|
+
*/
|
|
73
|
+
function extractParentFeatureId(command) {
|
|
74
|
+
// Match: jettypod work create chore "..." "..." --parent=123
|
|
75
|
+
// or: jettypod work create chore "..." "..." --parent 123
|
|
76
|
+
const match = command.match(/work\s+create\s+chore\s+.*--parent[=\s]+(\d+)/);
|
|
77
|
+
return match ? parseInt(match[1], 10) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if feature-planning skill is currently active
|
|
82
|
+
* @param {Object} hookInput - The hook input object
|
|
83
|
+
* @returns {boolean} True if skill is active
|
|
84
|
+
*/
|
|
85
|
+
function isSkillActive(hookInput) {
|
|
86
|
+
// Check for skill activation markers
|
|
87
|
+
// The skill system may set active_skill in context
|
|
88
|
+
if (hookInput.active_skill === 'feature-planning') {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check transcript for recent skill activation
|
|
93
|
+
const transcriptPath = hookInput.transcript_path;
|
|
94
|
+
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const transcript = fs.readFileSync(transcriptPath, 'utf8');
|
|
97
|
+
// Look for recent feature-planning skill activation
|
|
98
|
+
// The skill adds markers like "feature-planning skill is active"
|
|
99
|
+
const lines = transcript.split('\n').slice(-100); // Last 100 lines
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (line.includes('feature-planning') &&
|
|
102
|
+
(line.includes('skill is active') || line.includes('skill activated'))) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Ignore read errors
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a feature has been planned (has scenario_file and discovery_rationale)
|
|
116
|
+
* @param {string} cwd - Current working directory
|
|
117
|
+
* @param {number} featureId - The feature ID to check
|
|
118
|
+
* @returns {Promise<boolean>} True if feature is planned
|
|
119
|
+
*/
|
|
120
|
+
async function checkFeaturePlanned(cwd, featureId) {
|
|
121
|
+
// Find the database path
|
|
122
|
+
const dbPath = findDatabasePath(cwd);
|
|
123
|
+
if (!dbPath) {
|
|
124
|
+
// No database found, allow (not a jettypod project)
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Use better-sqlite3 for synchronous queries if available,
|
|
129
|
+
// otherwise fall back to spawning sqlite3 CLI
|
|
130
|
+
try {
|
|
131
|
+
const sqlite3 = require('better-sqlite3');
|
|
132
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
133
|
+
const row = db.prepare(
|
|
134
|
+
`SELECT scenario_file, discovery_rationale FROM work_items WHERE id = ? AND type = 'feature'`
|
|
135
|
+
).get(featureId);
|
|
136
|
+
db.close();
|
|
137
|
+
|
|
138
|
+
if (!row) return false;
|
|
139
|
+
const hasScenario = Boolean(row.scenario_file && row.scenario_file.trim());
|
|
140
|
+
const hasRationale = Boolean(row.discovery_rationale && row.discovery_rationale.trim());
|
|
141
|
+
return hasScenario && hasRationale;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// better-sqlite3 not available, use CLI
|
|
144
|
+
return checkFeaturePlannedCLI(dbPath, featureId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if feature is planned using sqlite3 CLI
|
|
150
|
+
* @param {string} dbPath - Path to database
|
|
151
|
+
* @param {number} featureId - Feature ID
|
|
152
|
+
* @returns {Promise<boolean>} True if planned
|
|
153
|
+
*/
|
|
154
|
+
function checkFeaturePlannedCLI(dbPath, featureId) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const { spawnSync } = require('child_process');
|
|
157
|
+
const result = spawnSync('sqlite3', [
|
|
158
|
+
dbPath,
|
|
159
|
+
`SELECT scenario_file, discovery_rationale FROM work_items WHERE id = ${featureId} AND type = 'feature'`
|
|
160
|
+
], { encoding: 'utf-8' });
|
|
161
|
+
|
|
162
|
+
if (result.error || result.status !== 0) {
|
|
163
|
+
resolve(true); // On error, allow
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const output = result.stdout.trim();
|
|
168
|
+
if (!output) {
|
|
169
|
+
resolve(false); // Feature not found
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// SQLite output is pipe-delimited
|
|
174
|
+
const [scenarioFile, rationale] = output.split('|');
|
|
175
|
+
const hasScenario = Boolean(scenarioFile && scenarioFile.trim());
|
|
176
|
+
const hasRationale = Boolean(rationale && rationale.trim());
|
|
177
|
+
resolve(hasScenario && hasRationale);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find the jettypod database path
|
|
183
|
+
* @param {string} cwd - Starting directory
|
|
184
|
+
* @returns {string|null} Database path or null
|
|
185
|
+
*/
|
|
186
|
+
function findDatabasePath(cwd) {
|
|
187
|
+
let dir = cwd;
|
|
188
|
+
while (dir !== path.dirname(dir)) {
|
|
189
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
190
|
+
if (fs.existsSync(dbPath)) {
|
|
191
|
+
return dbPath;
|
|
192
|
+
}
|
|
193
|
+
dir = path.dirname(dir);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Allow the action
|
|
200
|
+
*/
|
|
201
|
+
function allow() {
|
|
202
|
+
console.log(JSON.stringify({
|
|
203
|
+
hookSpecificOutput: {
|
|
204
|
+
hookEventName: "PreToolUse",
|
|
205
|
+
permissionDecision: "allow"
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Deny the action with explanation
|
|
213
|
+
*/
|
|
214
|
+
function deny(message, suggestion) {
|
|
215
|
+
const reason = `❌ ${message}\n\n💡 Hint: ${suggestion}`;
|
|
216
|
+
|
|
217
|
+
console.log(JSON.stringify({
|
|
218
|
+
hookSpecificOutput: {
|
|
219
|
+
hookEventName: "PreToolUse",
|
|
220
|
+
permissionDecision: "deny",
|
|
221
|
+
permissionDecisionReason: reason
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
package/jettypod.js
CHANGED
|
@@ -756,6 +756,14 @@ async function initializeProject() {
|
|
|
756
756
|
console.log('🔒 Claude Code hook installed');
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
// Install enforce-skill-activation hook
|
|
760
|
+
const enforceHookSource = path.join(__dirname, 'claude-hooks', 'enforce-skill-activation.js');
|
|
761
|
+
const enforceHookDest = path.join('.jettypod', 'hooks', 'enforce-skill-activation.js');
|
|
762
|
+
if (fs.existsSync(enforceHookSource)) {
|
|
763
|
+
fs.copyFileSync(enforceHookSource, enforceHookDest);
|
|
764
|
+
fs.chmodSync(enforceHookDest, 0o755);
|
|
765
|
+
}
|
|
766
|
+
|
|
759
767
|
// Create Claude Code settings
|
|
760
768
|
if (!fs.existsSync('.claude')) {
|
|
761
769
|
fs.mkdirSync('.claude', { recursive: true });
|
|
@@ -776,6 +784,10 @@ async function initializeProject() {
|
|
|
776
784
|
{
|
|
777
785
|
matcher: 'Write',
|
|
778
786
|
hooks: [{ type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }]
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
matcher: 'Bash',
|
|
790
|
+
hooks: [{ type: 'command', command: '.jettypod/hooks/enforce-skill-activation.js' }]
|
|
779
791
|
}
|
|
780
792
|
]
|
|
781
793
|
}
|
|
@@ -2171,6 +2183,47 @@ Quick commands:
|
|
|
2171
2183
|
break;
|
|
2172
2184
|
}
|
|
2173
2185
|
|
|
2186
|
+
case 'workflow': {
|
|
2187
|
+
const workflowSubcommand = args[0];
|
|
2188
|
+
|
|
2189
|
+
if (workflowSubcommand === 'resume') {
|
|
2190
|
+
const { getDb } = require('./lib/database');
|
|
2191
|
+
const { getCheckpoint, getCurrentBranch } = require('./lib/workflow-checkpoint');
|
|
2192
|
+
|
|
2193
|
+
try {
|
|
2194
|
+
const db = getDb();
|
|
2195
|
+
const branchName = getCurrentBranch();
|
|
2196
|
+
const checkpoint = await getCheckpoint(db, branchName);
|
|
2197
|
+
|
|
2198
|
+
if (!checkpoint) {
|
|
2199
|
+
console.log('No interrupted workflow found');
|
|
2200
|
+
} else {
|
|
2201
|
+
const stepInfo = checkpoint.total_steps
|
|
2202
|
+
? `Step ${checkpoint.current_step} of ${checkpoint.total_steps}`
|
|
2203
|
+
: `Step ${checkpoint.current_step}`;
|
|
2204
|
+
|
|
2205
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
2206
|
+
console.log('Found interrupted workflow');
|
|
2207
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
2208
|
+
console.log(`Skill: ${checkpoint.skill_name}`);
|
|
2209
|
+
console.log(stepInfo);
|
|
2210
|
+
if (checkpoint.work_item_id) {
|
|
2211
|
+
console.log(`Work Item: #${checkpoint.work_item_id}`);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
} catch (err) {
|
|
2215
|
+
console.error(`Error: ${err.message}`);
|
|
2216
|
+
process.exit(1);
|
|
2217
|
+
}
|
|
2218
|
+
} else {
|
|
2219
|
+
console.log('Usage: jettypod workflow resume');
|
|
2220
|
+
console.log('');
|
|
2221
|
+
console.log('Commands:');
|
|
2222
|
+
console.log(' resume Check for and resume interrupted workflows');
|
|
2223
|
+
}
|
|
2224
|
+
break;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2174
2227
|
default:
|
|
2175
2228
|
// Smart mode: auto-initialize if needed, otherwise show guidance
|
|
2176
2229
|
if (!fs.existsSync('.jettypod')) {
|
package/lib/current-work.js
CHANGED
|
@@ -34,34 +34,26 @@ async function getCurrentWork() {
|
|
|
34
34
|
// Not in a git repo or other git error - that's ok
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// If no work item ID from branch, we're on main - return null
|
|
38
|
+
// Current work only applies within worktrees, not root/main branch
|
|
39
|
+
if (!workItemId) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Query for the specific in_progress work item matching our branch
|
|
38
44
|
let row;
|
|
39
45
|
try {
|
|
40
46
|
row = await new Promise((resolve, reject) => {
|
|
41
|
-
const query =
|
|
42
|
-
? // If we have an ID from branch name, get that specific item
|
|
43
|
-
`SELECT
|
|
47
|
+
const query = `SELECT
|
|
44
48
|
wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
|
|
45
49
|
parent.title as parent_title,
|
|
46
50
|
epic.title as epic_title
|
|
47
51
|
FROM work_items wi
|
|
48
52
|
LEFT JOIN work_items parent ON wi.parent_id = parent.id
|
|
49
53
|
LEFT JOIN work_items epic ON wi.epic_id = epic.id
|
|
50
|
-
WHERE wi.id = ? AND wi.status = 'in_progress'
|
|
51
|
-
: // Otherwise get any in_progress item (for main branch)
|
|
52
|
-
`SELECT
|
|
53
|
-
wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
|
|
54
|
-
parent.title as parent_title,
|
|
55
|
-
epic.title as epic_title
|
|
56
|
-
FROM work_items wi
|
|
57
|
-
LEFT JOIN work_items parent ON wi.parent_id = parent.id
|
|
58
|
-
LEFT JOIN work_items epic ON wi.epic_id = epic.id
|
|
59
|
-
WHERE wi.status = 'in_progress'
|
|
60
|
-
LIMIT 1`;
|
|
61
|
-
|
|
62
|
-
const params = workItemId ? [workItemId] : [];
|
|
54
|
+
WHERE wi.id = ? AND wi.status = 'in_progress'`;
|
|
63
55
|
|
|
64
|
-
db.get(query,
|
|
56
|
+
db.get(query, [workItemId], (err, row) => {
|
|
65
57
|
if (err) {
|
|
66
58
|
return reject(err);
|
|
67
59
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: Create workflow_checkpoints table
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Enable persistence of workflow state across sessions so interrupted
|
|
5
|
+
* workflows can be resumed. Stores skill name, current step, and context JSON.
|
|
6
|
+
*
|
|
7
|
+
* Why this is critical:
|
|
8
|
+
* - Crash recovery - resume workflows after unexpected session termination
|
|
9
|
+
* - Branch-aware - checkpoints are tied to specific worktree/branch context
|
|
10
|
+
* - Context preservation - stores full workflow state as JSON for resume
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
id: '016-workflow-checkpoints-table',
|
|
15
|
+
description: 'Create workflow_checkpoints table for session resume capability',
|
|
16
|
+
|
|
17
|
+
async up(db) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
db.run(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS workflow_checkpoints (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
skill_name TEXT NOT NULL,
|
|
23
|
+
current_step INTEGER NOT NULL,
|
|
24
|
+
total_steps INTEGER,
|
|
25
|
+
context_json TEXT,
|
|
26
|
+
branch_name TEXT NOT NULL,
|
|
27
|
+
work_item_id INTEGER,
|
|
28
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
30
|
+
)
|
|
31
|
+
`, (err) => {
|
|
32
|
+
if (err) {
|
|
33
|
+
return reject(err);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create index on branch_name for quick lookup by current branch
|
|
37
|
+
db.run(`
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_checkpoints_branch
|
|
39
|
+
ON workflow_checkpoints(branch_name)
|
|
40
|
+
`, (err) => {
|
|
41
|
+
if (err) {
|
|
42
|
+
return reject(err);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async down(db) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
// Drop index first
|
|
54
|
+
db.run('DROP INDEX IF EXISTS idx_workflow_checkpoints_branch', (err) => {
|
|
55
|
+
if (err) {
|
|
56
|
+
return reject(err);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Drop table
|
|
60
|
+
db.run('DROP TABLE IF EXISTS workflow_checkpoints', (err) => {
|
|
61
|
+
if (err) {
|
|
62
|
+
return reject(err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 017: Backfill epic_id field
|
|
3
|
+
*
|
|
4
|
+
* The epic_id field provides a direct reference to the top-level epic,
|
|
5
|
+
* avoiding tree traversal when querying items by epic.
|
|
6
|
+
*
|
|
7
|
+
* This migration backfills epic_id for existing items:
|
|
8
|
+
* - Features: epic_id = parent_id (where parent is an epic)
|
|
9
|
+
* - Chores: epic_id = parent's epic_id (inherit from parent)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
id: '017-backfill-epic-id',
|
|
14
|
+
|
|
15
|
+
up: (db) => {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
db.serialize(() => {
|
|
18
|
+
// Backfill epic_id for features (parent is epic)
|
|
19
|
+
db.run(`
|
|
20
|
+
UPDATE work_items
|
|
21
|
+
SET epic_id = parent_id
|
|
22
|
+
WHERE type = 'feature'
|
|
23
|
+
AND parent_id IS NOT NULL
|
|
24
|
+
AND epic_id IS NULL
|
|
25
|
+
AND (SELECT type FROM work_items p WHERE p.id = work_items.parent_id) = 'epic'
|
|
26
|
+
`, (err) => {
|
|
27
|
+
if (err) return reject(err);
|
|
28
|
+
|
|
29
|
+
// Backfill epic_id for chores (inherit from parent)
|
|
30
|
+
db.run(`
|
|
31
|
+
UPDATE work_items
|
|
32
|
+
SET epic_id = (SELECT epic_id FROM work_items p WHERE p.id = work_items.parent_id)
|
|
33
|
+
WHERE type = 'chore'
|
|
34
|
+
AND parent_id IS NOT NULL
|
|
35
|
+
AND epic_id IS NULL
|
|
36
|
+
`, (err) => {
|
|
37
|
+
if (err) return reject(err);
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
down: (db) => {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
// Clear all epic_id values
|
|
48
|
+
db.run(`UPDATE work_items SET epic_id = NULL`, (err) => {
|
|
49
|
+
if (err) return reject(err);
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning status helpers for checking if features have been through feature-planning
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getDb } = require('./database');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a feature has been planned (has scenario_file and discovery_rationale)
|
|
9
|
+
* @param {number} featureId - The work item ID to check
|
|
10
|
+
* @returns {Promise<boolean>} True if feature has been planned
|
|
11
|
+
*/
|
|
12
|
+
function isFeaturePlanned(featureId) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
|
|
16
|
+
db.get(
|
|
17
|
+
`SELECT scenario_file, discovery_rationale
|
|
18
|
+
FROM work_items
|
|
19
|
+
WHERE id = ? AND type = 'feature'`,
|
|
20
|
+
[featureId],
|
|
21
|
+
(err, row) => {
|
|
22
|
+
if (err) {
|
|
23
|
+
reject(err);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!row) {
|
|
28
|
+
// Feature doesn't exist
|
|
29
|
+
resolve(false);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Feature is planned if both fields are non-null and non-empty
|
|
34
|
+
const hasScenario = Boolean(row.scenario_file && row.scenario_file.trim());
|
|
35
|
+
const hasRationale = Boolean(row.discovery_rationale && row.discovery_rationale.trim());
|
|
36
|
+
|
|
37
|
+
resolve(hasScenario && hasRationale);
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the parent feature ID for a chore
|
|
45
|
+
* @param {string} command - The jettypod command being executed
|
|
46
|
+
* @returns {number|null} The parent feature ID or null if not a chore creation
|
|
47
|
+
*/
|
|
48
|
+
function extractParentFeatureId(command) {
|
|
49
|
+
// Match: jettypod work create chore "..." "..." --parent=123
|
|
50
|
+
const match = command.match(/work\s+create\s+chore\s+.*--parent[=\s]+(\d+)/);
|
|
51
|
+
return match ? parseInt(match[1], 10) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a file path looks like a feature scenario file
|
|
56
|
+
* @param {string} filePath - The file path to check
|
|
57
|
+
* @returns {boolean} True if it's a .feature file in the features directory
|
|
58
|
+
*/
|
|
59
|
+
function isFeatureFile(filePath) {
|
|
60
|
+
if (!filePath) return false;
|
|
61
|
+
return filePath.includes('/features/') && filePath.endsWith('.feature');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
isFeaturePlanned,
|
|
66
|
+
extractParentFeatureId,
|
|
67
|
+
isFeatureFile
|
|
68
|
+
};
|