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.
Files changed (34) hide show
  1. package/apps/dashboard/README.md +36 -0
  2. package/apps/dashboard/app/favicon.ico +0 -0
  3. package/apps/dashboard/app/globals.css +122 -0
  4. package/apps/dashboard/app/layout.tsx +34 -0
  5. package/apps/dashboard/app/page.tsx +25 -0
  6. package/apps/dashboard/app/work/[id]/page.tsx +193 -0
  7. package/apps/dashboard/components/KanbanBoard.tsx +201 -0
  8. package/apps/dashboard/components/WorkItemTree.tsx +116 -0
  9. package/apps/dashboard/components.json +22 -0
  10. package/apps/dashboard/eslint.config.mjs +18 -0
  11. package/apps/dashboard/lib/db.ts +270 -0
  12. package/apps/dashboard/lib/utils.ts +6 -0
  13. package/apps/dashboard/next.config.ts +7 -0
  14. package/apps/dashboard/package.json +33 -0
  15. package/apps/dashboard/postcss.config.mjs +7 -0
  16. package/apps/dashboard/public/file.svg +1 -0
  17. package/apps/dashboard/public/globe.svg +1 -0
  18. package/apps/dashboard/public/next.svg +1 -0
  19. package/apps/dashboard/public/vercel.svg +1 -0
  20. package/apps/dashboard/public/window.svg +1 -0
  21. package/apps/dashboard/tsconfig.json +34 -0
  22. package/claude-hooks/enforce-skill-activation.js +225 -0
  23. package/jettypod.js +53 -0
  24. package/lib/current-work.js +10 -18
  25. package/lib/migrations/016-workflow-checkpoints-table.js +70 -0
  26. package/lib/migrations/017-backfill-epic-id.js +54 -0
  27. package/lib/planning-status.js +68 -0
  28. package/lib/workflow-checkpoint.js +204 -0
  29. package/package.json +7 -2
  30. package/skills-templates/chore-mode/SKILL.md +3 -0
  31. package/skills-templates/epic-planning/SKILL.md +225 -154
  32. package/skills-templates/feature-planning/SKILL.md +172 -87
  33. package/skills-templates/speed-mode/SKILL.md +161 -338
  34. 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')) {
@@ -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
- // Query for in_progress work item
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 = workItemId
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, params, (err, row) => {
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
+ };