jettypod 4.4.118 → 4.4.121
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/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
//! Command handler implementations.
|
|
2
|
+
//!
|
|
3
|
+
//! Each function corresponds to a CLI command. They receive a shared
|
|
4
|
+
//! [`CommandContext`] (project root + open database handle), call into
|
|
5
|
+
//! `jettypod_core`, and format output for the terminal.
|
|
6
|
+
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
|
|
9
|
+
use anyhow::{bail, Context, Result};
|
|
10
|
+
use jettypod_core::db::Database;
|
|
11
|
+
use jettypod_core::work::{self, CreateOptions, ItemType, Mode, Status};
|
|
12
|
+
use jettypod_core::{config, git, sessions, skills, worktree};
|
|
13
|
+
|
|
14
|
+
use crate::{PrototypeAction, TestAction};
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Shared context
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/// Shared context for CLI commands — holds the project root and an open database handle.
|
|
21
|
+
pub struct CommandContext {
|
|
22
|
+
pub root: PathBuf,
|
|
23
|
+
pub db: Database,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl CommandContext {
|
|
27
|
+
pub fn new(root: &Path) -> Result<Self> {
|
|
28
|
+
let db = Database::open(root).context("Failed to open project database")?;
|
|
29
|
+
Ok(Self {
|
|
30
|
+
root: root.to_path_buf(),
|
|
31
|
+
db,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
fn type_icon(t: &ItemType) -> &'static str {
|
|
41
|
+
match t {
|
|
42
|
+
ItemType::Epic => "📦",
|
|
43
|
+
ItemType::Feature => "✨",
|
|
44
|
+
ItemType::Chore => "🔧",
|
|
45
|
+
ItemType::Bug => "🐛",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn status_icon(s: &Status) -> &'static str {
|
|
50
|
+
match s {
|
|
51
|
+
Status::Backlog => "📋",
|
|
52
|
+
Status::Todo => "📝",
|
|
53
|
+
Status::InProgress => "🔄",
|
|
54
|
+
Status::Blocked => "🚫",
|
|
55
|
+
Status::Done => "✅",
|
|
56
|
+
Status::Cancelled => "❌",
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Init
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
pub fn init(name: Option<String>) -> Result<()> {
|
|
65
|
+
let cwd = std::env::current_dir()?;
|
|
66
|
+
|
|
67
|
+
let project_name = name.unwrap_or_else(|| {
|
|
68
|
+
cwd.file_name()
|
|
69
|
+
.unwrap_or_default()
|
|
70
|
+
.to_string_lossy()
|
|
71
|
+
.to_string()
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let jettypod_dir = cwd.join(".jettypod");
|
|
75
|
+
if jettypod_dir.exists() {
|
|
76
|
+
println!("Project already initialized: {project_name}");
|
|
77
|
+
// Still sync skills and regenerate CLAUDE.md.
|
|
78
|
+
} else {
|
|
79
|
+
std::fs::create_dir_all(&jettypod_dir)?;
|
|
80
|
+
println!("Created .jettypod/");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ensure database exists with schema.
|
|
84
|
+
let _db = Database::open(&cwd)?;
|
|
85
|
+
println!("Database ready: .jettypod/work.db");
|
|
86
|
+
|
|
87
|
+
// Write config if it doesn't exist.
|
|
88
|
+
let config_path = jettypod_dir.join("config.json");
|
|
89
|
+
if !config_path.exists() {
|
|
90
|
+
let cfg = jettypod_core::config::ProjectConfig {
|
|
91
|
+
name: project_name.clone(),
|
|
92
|
+
stage: "empty".into(),
|
|
93
|
+
bundles: vec!["core".into()],
|
|
94
|
+
project_state: "internal".into(),
|
|
95
|
+
project_discovery: Default::default(),
|
|
96
|
+
main_branch: None,
|
|
97
|
+
extra: Default::default(),
|
|
98
|
+
};
|
|
99
|
+
config::write(&cwd, &cfg)?;
|
|
100
|
+
println!("Created config: .jettypod/config.json");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sync skills.
|
|
104
|
+
let source = skills::default_source_dir(&cwd);
|
|
105
|
+
let dest = skills::default_dest_dir(&cwd);
|
|
106
|
+
match skills::sync_skills(&skills::SyncOptions {
|
|
107
|
+
source_dir: source,
|
|
108
|
+
dest_dir: dest,
|
|
109
|
+
}) {
|
|
110
|
+
Ok(result) => {
|
|
111
|
+
if let Some(warning) = result.warning {
|
|
112
|
+
println!("Skills: {warning}");
|
|
113
|
+
} else {
|
|
114
|
+
println!("Skills: {} files synced", result.files_copied);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
Err(e) => eprintln!("Warning: Skills sync failed: {e}"),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
println!("\n✅ Initialized JettyPod project: {project_name}");
|
|
121
|
+
Ok(())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Describe / Generate
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
pub fn describe(root: &Path, description: &str) -> Result<()> {
|
|
129
|
+
let mut cfg = config::read(root);
|
|
130
|
+
cfg.extra.insert(
|
|
131
|
+
"description".into(),
|
|
132
|
+
serde_json::Value::String(description.to_string()),
|
|
133
|
+
);
|
|
134
|
+
config::write(root, &cfg)?;
|
|
135
|
+
println!("Updated project description.");
|
|
136
|
+
Ok(())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub fn generate(root: &Path) -> Result<()> {
|
|
140
|
+
// CLAUDE.md generation is handled by the Node.js template engine.
|
|
141
|
+
// For now, just confirm config exists.
|
|
142
|
+
let cfg = config::read(root);
|
|
143
|
+
println!("Project: {}", cfg.name);
|
|
144
|
+
println!("State: {}", cfg.project_state);
|
|
145
|
+
println!("CLAUDE.md regeneration requires the template engine (not yet ported).");
|
|
146
|
+
Ok(())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Work: Create
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
pub fn work_create(
|
|
154
|
+
ctx: &CommandContext,
|
|
155
|
+
from: Option<String>,
|
|
156
|
+
item_type: Option<String>,
|
|
157
|
+
title: Option<String>,
|
|
158
|
+
description: Option<String>,
|
|
159
|
+
parent: Option<i64>,
|
|
160
|
+
mode: Option<String>,
|
|
161
|
+
needs_discovery: bool,
|
|
162
|
+
) -> Result<()> {
|
|
163
|
+
// Parse from JSON file if --from specified.
|
|
164
|
+
let (typ, ttl, desc, par, md, nd) = if let Some(path) = from {
|
|
165
|
+
let content = std::fs::read_to_string(&path)
|
|
166
|
+
.with_context(|| format!("Failed to read {path}"))?;
|
|
167
|
+
let v: serde_json::Value = serde_json::from_str(&content)
|
|
168
|
+
.with_context(|| format!("Invalid JSON in {path}"))?;
|
|
169
|
+
|
|
170
|
+
let t = v["type"].as_str().unwrap_or("feature").to_string();
|
|
171
|
+
let ti = v["title"]
|
|
172
|
+
.as_str()
|
|
173
|
+
.map(|s| s.to_string())
|
|
174
|
+
.unwrap_or_default();
|
|
175
|
+
let d = v["description"].as_str().map(|s| s.to_string());
|
|
176
|
+
let p = v["parent"].as_i64();
|
|
177
|
+
let m = v["mode"].as_str().map(|s| s.to_string());
|
|
178
|
+
let n = v["needsDiscovery"].as_bool().unwrap_or(false);
|
|
179
|
+
(t, ti, d, p, m, n)
|
|
180
|
+
} else {
|
|
181
|
+
let t = item_type.unwrap_or_else(|| "feature".into());
|
|
182
|
+
let ti = title.unwrap_or_default();
|
|
183
|
+
(t, ti, description, parent, mode, needs_discovery)
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if ttl.is_empty() {
|
|
187
|
+
bail!("Title is required");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let item_type = typ.parse::<ItemType>()?;
|
|
191
|
+
let md_parsed = md.map(|m| m.parse::<Mode>()).transpose()?;
|
|
192
|
+
|
|
193
|
+
let id = work::create(
|
|
194
|
+
&ctx.db,
|
|
195
|
+
item_type,
|
|
196
|
+
&ttl,
|
|
197
|
+
CreateOptions {
|
|
198
|
+
description: desc.unwrap_or_default(),
|
|
199
|
+
parent_id: par,
|
|
200
|
+
mode: md_parsed,
|
|
201
|
+
needs_discovery: nd,
|
|
202
|
+
conversational: false,
|
|
203
|
+
},
|
|
204
|
+
)?;
|
|
205
|
+
|
|
206
|
+
println!(
|
|
207
|
+
"✅ Created {} #{}: {}",
|
|
208
|
+
type_icon(&item_type),
|
|
209
|
+
id,
|
|
210
|
+
ttl
|
|
211
|
+
);
|
|
212
|
+
Ok(())
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Work: Start (create worktree)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
pub fn work_start(ctx: &CommandContext, id: i64) -> Result<()> {
|
|
220
|
+
let item = work::get(&ctx.db, id)?;
|
|
221
|
+
|
|
222
|
+
// Check if worktree already exists.
|
|
223
|
+
if let Some(existing) = worktree::get_for_work_item(&ctx.db, id)? {
|
|
224
|
+
if existing.status == worktree::WorktreeStatus::Active {
|
|
225
|
+
println!(
|
|
226
|
+
"📁 Worktree already exists: {}",
|
|
227
|
+
existing.worktree_path
|
|
228
|
+
);
|
|
229
|
+
println!("Working on: [#{}] {} ({})", item.id, item.title, item.item_type);
|
|
230
|
+
return Ok(());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Create worktree.
|
|
235
|
+
let wt = worktree::create(
|
|
236
|
+
&ctx.db,
|
|
237
|
+
&worktree::CreateWorktreeOpts {
|
|
238
|
+
work_item_id: id,
|
|
239
|
+
title: &item.title,
|
|
240
|
+
repo_root: &ctx.root,
|
|
241
|
+
},
|
|
242
|
+
)?;
|
|
243
|
+
|
|
244
|
+
// Mark as current and in_progress.
|
|
245
|
+
work::set_current(&ctx.db, id)?;
|
|
246
|
+
if item.status == Status::Backlog || item.status == Status::Todo {
|
|
247
|
+
work::update_status(&ctx.db, id, Status::InProgress)?;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Set branch name.
|
|
251
|
+
work::set_branch(&ctx.db, id, &wt.branch_name)?;
|
|
252
|
+
|
|
253
|
+
// Write session file.
|
|
254
|
+
let epic = work::find_epic(&ctx.db, id)?;
|
|
255
|
+
if let Err(e) = sessions::write_session_file(
|
|
256
|
+
&ctx.root,
|
|
257
|
+
id,
|
|
258
|
+
&item.title,
|
|
259
|
+
item.item_type.as_ref(),
|
|
260
|
+
item.status.as_ref(),
|
|
261
|
+
item.mode.as_ref().map(|m| m.as_ref()).unwrap_or(""),
|
|
262
|
+
epic.as_ref().map(|e| e.id),
|
|
263
|
+
epic.as_ref().map(|e| e.title.as_str()),
|
|
264
|
+
) {
|
|
265
|
+
eprintln!("Warning: Failed to write session file: {e}");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
println!("✅ Created worktree: {}", wt.worktree_path);
|
|
269
|
+
println!(
|
|
270
|
+
"📁 IMPORTANT: Use absolute paths for file operations:\n {}/lib/file.js (correct)\n lib/file.js (wrong - creates in main repo)",
|
|
271
|
+
wt.worktree_path
|
|
272
|
+
);
|
|
273
|
+
println!(
|
|
274
|
+
"Working on: [#{}] {} ({})",
|
|
275
|
+
item.id, item.title, item.item_type
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if let Some(parent_id) = item.parent_id {
|
|
279
|
+
if let Ok(parent) = work::get(&ctx.db, parent_id) {
|
|
280
|
+
if parent.scenario_file.is_none() {
|
|
281
|
+
println!(
|
|
282
|
+
"\n⚠️ Warning: Parent feature has no scenario file\n\n\
|
|
283
|
+
Parent feature #{} \"{}\" does not have\n\
|
|
284
|
+
a scenario file set.\n\n\
|
|
285
|
+
Suggestion: Create a BDD scenario file for the feature and update scenario_file.",
|
|
286
|
+
parent.id, parent.title
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
Ok(())
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Work: Stop
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
pub fn work_stop(ctx: &CommandContext, status: Option<String>) -> Result<()> {
|
|
300
|
+
// Find current work item.
|
|
301
|
+
let items = work::get_tree(&ctx.db, false)?;
|
|
302
|
+
let current = items.iter().find(|i| i.current);
|
|
303
|
+
|
|
304
|
+
match current {
|
|
305
|
+
Some(item) => {
|
|
306
|
+
if let Some(st) = status {
|
|
307
|
+
let new_status = st.parse::<Status>()?;
|
|
308
|
+
work::update_status(&ctx.db, item.id, new_status)?;
|
|
309
|
+
println!("Updated #{} status to {}", item.id, st);
|
|
310
|
+
}
|
|
311
|
+
// Clear current.
|
|
312
|
+
if let Err(e) = ctx.db.conn().execute(
|
|
313
|
+
"UPDATE work_items SET current = 0 WHERE id = ?",
|
|
314
|
+
rusqlite::params![item.id],
|
|
315
|
+
) {
|
|
316
|
+
eprintln!("Warning: Failed to clear current work item: {e}");
|
|
317
|
+
}
|
|
318
|
+
// Clear session file.
|
|
319
|
+
if let Err(e) = sessions::clear_session_file(&ctx.root) {
|
|
320
|
+
eprintln!("Warning: Failed to clear session file: {e}");
|
|
321
|
+
}
|
|
322
|
+
println!("Stopped working on #{}: {}", item.id, item.title);
|
|
323
|
+
}
|
|
324
|
+
None => {
|
|
325
|
+
println!("No active work item.");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
Ok(())
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Work: Status update
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
pub fn work_status(ctx: &CommandContext, id: i64, status: &str) -> Result<()> {
|
|
337
|
+
let new_status = status.parse::<Status>()?;
|
|
338
|
+
work::update_status(&ctx.db, id, new_status)?;
|
|
339
|
+
|
|
340
|
+
let item = work::get(&ctx.db, id)?;
|
|
341
|
+
println!(
|
|
342
|
+
"{} #{} \"{}\" → {}",
|
|
343
|
+
status_icon(&new_status),
|
|
344
|
+
id,
|
|
345
|
+
item.title,
|
|
346
|
+
status
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Mark complete with ready_for_review if done.
|
|
350
|
+
if new_status == Status::Done {
|
|
351
|
+
println!("✅ {} #{} marked done.", type_icon(&item.item_type), id);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
Ok(())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Work: Current
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
pub fn work_current(ctx: &CommandContext) -> Result<()> {
|
|
362
|
+
let items = work::get_tree(&ctx.db, false)?;
|
|
363
|
+
let current = items.iter().find(|i| i.current);
|
|
364
|
+
|
|
365
|
+
match current {
|
|
366
|
+
Some(item) => {
|
|
367
|
+
println!("{} #{}: {}", type_icon(&item.item_type), item.id, item.title);
|
|
368
|
+
println!(" Status: {} {}", status_icon(&item.status), item.status);
|
|
369
|
+
if let Some(ref mode) = item.mode {
|
|
370
|
+
println!(" Mode: {mode}");
|
|
371
|
+
}
|
|
372
|
+
if let Some(ref branch) = item.branch_name {
|
|
373
|
+
println!(" Branch: {branch}");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
None => println!("No active work item."),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
Ok(())
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Work: Show
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
pub fn work_show(ctx: &CommandContext, id: i64) -> Result<()> {
|
|
387
|
+
let item = work::get(&ctx.db, id)?;
|
|
388
|
+
|
|
389
|
+
println!(
|
|
390
|
+
"{} #{}: {}",
|
|
391
|
+
type_icon(&item.item_type),
|
|
392
|
+
item.id,
|
|
393
|
+
item.title
|
|
394
|
+
);
|
|
395
|
+
println!(" Type: {}", item.item_type);
|
|
396
|
+
println!(" Status: {} {}", status_icon(&item.status), item.status);
|
|
397
|
+
|
|
398
|
+
if let Some(ref mode) = item.mode {
|
|
399
|
+
println!(" Mode: {mode}");
|
|
400
|
+
}
|
|
401
|
+
if let Some(ref phase) = item.phase {
|
|
402
|
+
println!(" Phase: {phase}");
|
|
403
|
+
}
|
|
404
|
+
if let Some(parent_id) = item.parent_id {
|
|
405
|
+
if let Ok(parent) = work::get(&ctx.db, parent_id) {
|
|
406
|
+
println!(
|
|
407
|
+
" Parent: {} #{} {}",
|
|
408
|
+
type_icon(&parent.item_type),
|
|
409
|
+
parent.id,
|
|
410
|
+
parent.title
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if let Some(ref branch) = item.branch_name {
|
|
415
|
+
println!(" Branch: {branch}");
|
|
416
|
+
}
|
|
417
|
+
if let Some(ref desc) = item.description {
|
|
418
|
+
if !desc.is_empty() {
|
|
419
|
+
println!("\n {desc}");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if let Some(ref created) = item.created_at {
|
|
423
|
+
println!(" Created: {created}");
|
|
424
|
+
}
|
|
425
|
+
if let Some(ref completed) = item.completed_at {
|
|
426
|
+
println!(" Completed: {completed}");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Show decisions if epic.
|
|
430
|
+
if item.item_type == ItemType::Epic {
|
|
431
|
+
let decisions = work::get_decisions(&ctx.db, id)?;
|
|
432
|
+
if !decisions.is_empty() {
|
|
433
|
+
println!("\n Decisions:");
|
|
434
|
+
for d in &decisions {
|
|
435
|
+
println!(" • {}: {}", d.aspect, d.decision);
|
|
436
|
+
println!(" Rationale: {}", d.rationale);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Show children.
|
|
442
|
+
let children = work::get_children(&ctx.db, id)?;
|
|
443
|
+
if !children.is_empty() {
|
|
444
|
+
println!("\n Children:");
|
|
445
|
+
for child in &children {
|
|
446
|
+
println!(
|
|
447
|
+
" {} #{}: {} [{}]",
|
|
448
|
+
type_icon(&child.item_type),
|
|
449
|
+
child.id,
|
|
450
|
+
child.title,
|
|
451
|
+
child.status
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
Ok(())
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Work: Describe
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
pub fn work_describe(ctx: &CommandContext, id: i64, description: &str) -> Result<()> {
|
|
464
|
+
work::set_description(&ctx.db, id, description)?;
|
|
465
|
+
println!("Updated description for #{}.", id);
|
|
466
|
+
Ok(())
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Work: Children
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
pub fn work_children(ctx: &CommandContext, id: i64) -> Result<()> {
|
|
474
|
+
let children = work::get_children(&ctx.db, id)?;
|
|
475
|
+
|
|
476
|
+
if children.is_empty() {
|
|
477
|
+
println!("No children for #{id}.");
|
|
478
|
+
} else {
|
|
479
|
+
for child in &children {
|
|
480
|
+
println!(
|
|
481
|
+
"{} #{}: {} [{}]{}",
|
|
482
|
+
type_icon(&child.item_type),
|
|
483
|
+
child.id,
|
|
484
|
+
child.title,
|
|
485
|
+
child.status,
|
|
486
|
+
child.mode.as_ref().map(|m| format!(" ({m})")).unwrap_or_default()
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
Ok(())
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Work: Merge
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
pub fn work_merge(ctx: &CommandContext, id: Option<i64>, release_lock: bool) -> Result<()> {
|
|
499
|
+
// Find work item ID.
|
|
500
|
+
let work_id = match id {
|
|
501
|
+
Some(id) => id,
|
|
502
|
+
None => {
|
|
503
|
+
// Try to detect from current branch.
|
|
504
|
+
match sessions::get_current_work_from_branch(&ctx.db, &ctx.root)? {
|
|
505
|
+
Some(id) => id,
|
|
506
|
+
None => bail!("No work item ID provided and not in a worktree branch"),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
if release_lock {
|
|
512
|
+
if let Err(e) = ctx.db.conn().execute(
|
|
513
|
+
"DELETE FROM merge_locks WHERE work_item_id = ?",
|
|
514
|
+
rusqlite::params![work_id],
|
|
515
|
+
) {
|
|
516
|
+
eprintln!("Warning: Failed to release merge lock: {e}");
|
|
517
|
+
}
|
|
518
|
+
println!("Released merge lock for #{}.", work_id);
|
|
519
|
+
return Ok(());
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let item = work::get(&ctx.db, work_id)?;
|
|
523
|
+
let wt = worktree::get_for_work_item(&ctx.db, work_id)?
|
|
524
|
+
.ok_or_else(|| anyhow::anyhow!("No worktree found for #{work_id}"))?;
|
|
525
|
+
|
|
526
|
+
// Guard: block merge if parent feature has no QA steps set.
|
|
527
|
+
if item.item_type == ItemType::Chore || item.item_type == ItemType::Bug {
|
|
528
|
+
if let Some(parent_id) = item.parent_id {
|
|
529
|
+
let parent = work::get(&ctx.db, parent_id)?;
|
|
530
|
+
if parent.item_type == ItemType::Feature {
|
|
531
|
+
let has_qa = parent
|
|
532
|
+
.qa_steps
|
|
533
|
+
.as_ref()
|
|
534
|
+
.map(|s| !s.is_empty() && s != "[]")
|
|
535
|
+
.unwrap_or(false);
|
|
536
|
+
if !has_qa {
|
|
537
|
+
bail!(
|
|
538
|
+
"Cannot merge: QA steps not set on parent feature #{} \"{}\".\n\
|
|
539
|
+
Generate QA steps first:\n\n \
|
|
540
|
+
jettypod work set-qa-steps {} --from=<file>\n",
|
|
541
|
+
parent_id, parent.title, parent_id
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
println!("⏳ Acquiring merge lock...");
|
|
549
|
+
let lock_id = worktree::acquire_merge_lock(&ctx.db, work_id, "cli", 300)?;
|
|
550
|
+
println!("✅ Merge lock acquired");
|
|
551
|
+
|
|
552
|
+
println!("Merging work item #{}: {}", work_id, item.title);
|
|
553
|
+
println!("Branch: {}", wt.branch_name);
|
|
554
|
+
|
|
555
|
+
let merge_result = (|| -> Result<()> {
|
|
556
|
+
// Mark worktree as merging.
|
|
557
|
+
worktree::mark_status(&ctx.db, wt.id, worktree::WorktreeStatus::Merging)?;
|
|
558
|
+
|
|
559
|
+
let default_branch = config::resolve_default_branch(&ctx.root);
|
|
560
|
+
|
|
561
|
+
// Push feature branch.
|
|
562
|
+
println!("Pushing feature branch to remote...");
|
|
563
|
+
if let Err(e) = git::push_with_upstream(&ctx.root, &wt.branch_name) {
|
|
564
|
+
eprintln!("Warning: Failed to push feature branch: {e}");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Checkout main.
|
|
568
|
+
println!("Checking out {default_branch}...");
|
|
569
|
+
git::run(&ctx.root, &["checkout", &default_branch])?;
|
|
570
|
+
|
|
571
|
+
// Pull latest.
|
|
572
|
+
println!("Updating {default_branch} from remote...");
|
|
573
|
+
if let Err(e) = git::pull_origin(&ctx.root, &default_branch) {
|
|
574
|
+
eprintln!("Warning: Failed to pull {}: {e}", default_branch);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Merge.
|
|
578
|
+
println!("Merging {} into {default_branch}...", wt.branch_name);
|
|
579
|
+
git::merge_no_ff(&ctx.root, &wt.branch_name)?;
|
|
580
|
+
|
|
581
|
+
// Push main.
|
|
582
|
+
println!("Pushing {default_branch} to remote...");
|
|
583
|
+
if let Err(e) = git::run(&ctx.root, &["push"]) {
|
|
584
|
+
eprintln!("Warning: Failed to push {}: {e}", default_branch);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Mark work item status.
|
|
588
|
+
println!("✓ Successfully merged work item #{work_id}");
|
|
589
|
+
|
|
590
|
+
// Update status.
|
|
591
|
+
if item.item_type == ItemType::Chore || item.item_type == ItemType::Bug {
|
|
592
|
+
work::update_status(&ctx.db, work_id, Status::Done)?;
|
|
593
|
+
println!(
|
|
594
|
+
"✅ {} #{} ready for review",
|
|
595
|
+
type_icon(&item.item_type),
|
|
596
|
+
work_id
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
Ok(())
|
|
601
|
+
})();
|
|
602
|
+
|
|
603
|
+
// Always release lock.
|
|
604
|
+
if let Err(e) = worktree::release_merge_lock(&ctx.db, lock_id) {
|
|
605
|
+
eprintln!("Warning: Failed to release merge lock: {e}");
|
|
606
|
+
}
|
|
607
|
+
println!("✅ Merge lock released");
|
|
608
|
+
|
|
609
|
+
if let Err(e) = merge_result {
|
|
610
|
+
bail!("Merge failed: {e}");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
println!(
|
|
614
|
+
"\n⚠️ CLEANUP REQUIRED - Run these commands NOW:\n\n cd {}\n jettypod work cleanup {}\n\n Worktrees accumulate until cleaned up.",
|
|
615
|
+
ctx.root.display(),
|
|
616
|
+
work_id
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
Ok(())
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// Work: Cleanup
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
pub fn work_cleanup(ctx: &CommandContext, id: Option<i64>, force: bool, dry_run: bool) -> Result<()> {
|
|
627
|
+
match id {
|
|
628
|
+
Some(work_id) => {
|
|
629
|
+
let wt = worktree::get_for_work_item(&ctx.db, work_id)?
|
|
630
|
+
.ok_or_else(|| anyhow::anyhow!("No worktree found for #{work_id}"))?;
|
|
631
|
+
|
|
632
|
+
let item = work::get(&ctx.db, work_id)?;
|
|
633
|
+
println!("Cleaning up worktree for #{}: {}", work_id, item.title);
|
|
634
|
+
|
|
635
|
+
if dry_run {
|
|
636
|
+
println!(" Would remove: {}", wt.worktree_path);
|
|
637
|
+
println!(" Would delete branch: {}", wt.branch_name);
|
|
638
|
+
return Ok(());
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if !force
|
|
642
|
+
&& wt.status != worktree::WorktreeStatus::Merging
|
|
643
|
+
&& wt.status != worktree::WorktreeStatus::Active
|
|
644
|
+
{
|
|
645
|
+
println!("Worktree status is {} — nothing to clean.", wt.status);
|
|
646
|
+
return Ok(());
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
worktree::cleanup(
|
|
650
|
+
&ctx.db,
|
|
651
|
+
wt.id,
|
|
652
|
+
&worktree::CleanupWorktreeOpts {
|
|
653
|
+
repo_root: &ctx.root,
|
|
654
|
+
delete_branch: true,
|
|
655
|
+
},
|
|
656
|
+
)?;
|
|
657
|
+
|
|
658
|
+
println!("✅ Removed worktree directory");
|
|
659
|
+
println!("✅ Deleted branch");
|
|
660
|
+
println!("✅ Worktree cleaned up");
|
|
661
|
+
}
|
|
662
|
+
None => {
|
|
663
|
+
// Batch cleanup: find all non-active worktrees.
|
|
664
|
+
let active = worktree::get_all_active(&ctx.db)?;
|
|
665
|
+
if active.is_empty() {
|
|
666
|
+
println!("No active worktrees to clean up.");
|
|
667
|
+
return Ok(());
|
|
668
|
+
}
|
|
669
|
+
println!("Found {} active worktrees.", active.len());
|
|
670
|
+
for wt in &active {
|
|
671
|
+
let path = std::path::Path::new(&wt.worktree_path);
|
|
672
|
+
if !path.exists() {
|
|
673
|
+
println!(
|
|
674
|
+
" Cleaning orphan: {} (directory missing)",
|
|
675
|
+
wt.worktree_path
|
|
676
|
+
);
|
|
677
|
+
if !dry_run {
|
|
678
|
+
if let Err(e) = worktree::cleanup(
|
|
679
|
+
&ctx.db,
|
|
680
|
+
wt.id,
|
|
681
|
+
&worktree::CleanupWorktreeOpts {
|
|
682
|
+
repo_root: &ctx.root,
|
|
683
|
+
delete_branch: true,
|
|
684
|
+
},
|
|
685
|
+
) {
|
|
686
|
+
eprintln!("Warning: Failed to cleanup orphan worktree: {e}");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
Ok(())
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Work: Set mode / Elevate
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
pub fn work_set_mode(ctx: &CommandContext, id: i64, mode: &str) -> Result<()> {
|
|
702
|
+
work::set_mode(&ctx.db, id, mode)?;
|
|
703
|
+
println!("Set mode for #{} to {}", id, mode);
|
|
704
|
+
Ok(())
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
pub fn work_elevate(ctx: &CommandContext, id: i64, mode: &str) -> Result<()> {
|
|
708
|
+
let item = work::get(&ctx.db, id)?;
|
|
709
|
+
|
|
710
|
+
let target = mode.parse::<Mode>()?;
|
|
711
|
+
let valid_progression = match item.mode {
|
|
712
|
+
Some(Mode::Speed) => target == Mode::Stable || target == Mode::Production,
|
|
713
|
+
Some(Mode::Stable) => target == Mode::Production,
|
|
714
|
+
_ => false,
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
if !valid_progression {
|
|
718
|
+
bail!(
|
|
719
|
+
"Cannot elevate #{} from {:?} to {}",
|
|
720
|
+
id,
|
|
721
|
+
item.mode,
|
|
722
|
+
mode
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
work::set_mode(&ctx.db, id, mode)?;
|
|
727
|
+
println!(
|
|
728
|
+
"⬆️ Elevated #{} from {} → {}",
|
|
729
|
+
id,
|
|
730
|
+
item.mode.map(|m| m.to_string()).unwrap_or_default(),
|
|
731
|
+
mode
|
|
732
|
+
);
|
|
733
|
+
Ok(())
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// Work: Tests
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
pub fn work_tests(
|
|
741
|
+
ctx: &CommandContext,
|
|
742
|
+
action: Option<TestAction>,
|
|
743
|
+
id: Option<i64>,
|
|
744
|
+
) -> Result<()> {
|
|
745
|
+
match action {
|
|
746
|
+
Some(TestAction::Start { id: feature_id }) => {
|
|
747
|
+
create_test_worktree(ctx, feature_id)
|
|
748
|
+
}
|
|
749
|
+
Some(TestAction::Merge { id: feature_id }) => {
|
|
750
|
+
println!("Merging test worktree for #{}...", feature_id);
|
|
751
|
+
work_merge(ctx, Some(feature_id), false)
|
|
752
|
+
}
|
|
753
|
+
None => {
|
|
754
|
+
// Shorthand: `jettypod work tests <id>`
|
|
755
|
+
match id {
|
|
756
|
+
Some(feature_id) => create_test_worktree(ctx, feature_id),
|
|
757
|
+
None => bail!("Usage: jettypod work tests <feature-id> OR jettypod work tests start <id>"),
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fn create_test_worktree(ctx: &CommandContext, feature_id: i64) -> Result<()> {
|
|
764
|
+
let item = work::get(&ctx.db, feature_id)?;
|
|
765
|
+
println!("Creating test worktree for #{}: {}", feature_id, item.title);
|
|
766
|
+
let wt = worktree::create(
|
|
767
|
+
&ctx.db,
|
|
768
|
+
&worktree::CreateWorktreeOpts {
|
|
769
|
+
work_item_id: feature_id,
|
|
770
|
+
title: &format!("{}-tests", item.title),
|
|
771
|
+
repo_root: &ctx.root,
|
|
772
|
+
},
|
|
773
|
+
)?;
|
|
774
|
+
println!("✅ Test worktree created: {}", wt.worktree_path);
|
|
775
|
+
Ok(())
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
// Work: Prototype
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
pub fn work_prototype(ctx: &CommandContext, action: PrototypeAction) -> Result<()> {
|
|
783
|
+
match action {
|
|
784
|
+
PrototypeAction::Start { id, approach } => {
|
|
785
|
+
let item = work::get(&ctx.db, id)?;
|
|
786
|
+
println!(
|
|
787
|
+
"Creating prototype worktree for #{}: {} (approach: {})",
|
|
788
|
+
id, item.title, approach
|
|
789
|
+
);
|
|
790
|
+
let wt = worktree::create(
|
|
791
|
+
&ctx.db,
|
|
792
|
+
&worktree::CreateWorktreeOpts {
|
|
793
|
+
work_item_id: id,
|
|
794
|
+
title: &format!("{}-prototype-{}", item.title, approach),
|
|
795
|
+
repo_root: &ctx.root,
|
|
796
|
+
},
|
|
797
|
+
)?;
|
|
798
|
+
println!("✅ Prototype worktree created: {}", wt.worktree_path);
|
|
799
|
+
Ok(())
|
|
800
|
+
}
|
|
801
|
+
PrototypeAction::Merge { id } => {
|
|
802
|
+
println!("Merging prototype worktree for #{}...", id);
|
|
803
|
+
work_merge(ctx, Some(id), false)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// Work: Epic implement (record decision)
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
pub fn work_epic_implement(
|
|
813
|
+
ctx: &CommandContext,
|
|
814
|
+
id: i64,
|
|
815
|
+
aspect: &str,
|
|
816
|
+
decision: &str,
|
|
817
|
+
rationale: &str,
|
|
818
|
+
_prototypes: Option<String>,
|
|
819
|
+
) -> Result<()> {
|
|
820
|
+
let item = work::get(&ctx.db, id)?;
|
|
821
|
+
|
|
822
|
+
if item.item_type != ItemType::Epic {
|
|
823
|
+
bail!("#{} is a {} — epic-implement is only for epics", id, item.item_type);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
work::add_decision(&ctx.db, id, aspect, decision, rationale)?;
|
|
827
|
+
println!("✅ Architectural decision recorded for epic #{}", id);
|
|
828
|
+
println!(" Aspect: {aspect}");
|
|
829
|
+
println!(" Decision: {decision}");
|
|
830
|
+
println!(" Rationale: {rationale}");
|
|
831
|
+
Ok(())
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// Work: Set QA Steps
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
pub fn work_set_qa_steps(
|
|
839
|
+
ctx: &CommandContext,
|
|
840
|
+
id: i64,
|
|
841
|
+
from: Option<String>,
|
|
842
|
+
steps: Option<String>,
|
|
843
|
+
) -> Result<()> {
|
|
844
|
+
let json = if let Some(path) = from {
|
|
845
|
+
std::fs::read_to_string(&path)
|
|
846
|
+
.map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path, e))?
|
|
847
|
+
} else if let Some(s) = steps {
|
|
848
|
+
s
|
|
849
|
+
} else {
|
|
850
|
+
anyhow::bail!("Provide QA steps via --from=<file> or as an argument");
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Validate it's valid JSON array
|
|
854
|
+
let parsed: serde_json::Value = serde_json::from_str(&json)
|
|
855
|
+
.map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;
|
|
856
|
+
if !parsed.is_array() {
|
|
857
|
+
anyhow::bail!("QA steps must be a JSON array");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
work::set_qa_steps(&ctx.db, id, &json)?;
|
|
861
|
+
let count = parsed.as_array().map(|a| a.len()).unwrap_or(0);
|
|
862
|
+
println!("✅ Set {} QA steps for work item #{}", count, id);
|
|
863
|
+
Ok(())
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// Work: Implement (transition discovery → implementation)
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
pub fn work_implement(
|
|
871
|
+
ctx: &CommandContext,
|
|
872
|
+
id: i64,
|
|
873
|
+
_prototypes: Option<String>,
|
|
874
|
+
winner: Option<String>,
|
|
875
|
+
) -> Result<()> {
|
|
876
|
+
let item = work::get(&ctx.db, id)?;
|
|
877
|
+
|
|
878
|
+
if item.item_type != ItemType::Feature {
|
|
879
|
+
bail!("#{} is a {} — implement is only for features", id, item.item_type);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
work::complete_discovery(
|
|
883
|
+
&ctx.db,
|
|
884
|
+
id,
|
|
885
|
+
item.scenario_file.as_deref(),
|
|
886
|
+
winner.as_deref(),
|
|
887
|
+
None,
|
|
888
|
+
)?;
|
|
889
|
+
|
|
890
|
+
println!(
|
|
891
|
+
"✅ Feature #{} transitioned to implementation (mode: speed)",
|
|
892
|
+
id
|
|
893
|
+
);
|
|
894
|
+
Ok(())
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Backlog
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
pub fn backlog(ctx: &CommandContext, _expand: Option<&str>, expand_all: bool) -> Result<()> {
|
|
902
|
+
let items = work::get_tree(&ctx.db, false)?;
|
|
903
|
+
|
|
904
|
+
if items.is_empty() {
|
|
905
|
+
println!("Backlog is empty.");
|
|
906
|
+
return Ok(());
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Group by: top-level items (no parent) and their children.
|
|
910
|
+
let top_level: Vec<_> = items.iter().filter(|i| i.parent_id.is_none()).collect();
|
|
911
|
+
|
|
912
|
+
// Recently completed (last 5).
|
|
913
|
+
let completed = {
|
|
914
|
+
let all = work::get_tree(&ctx.db, true)?;
|
|
915
|
+
let mut done: Vec<_> = all.iter().filter(|i| i.status == Status::Done).cloned().collect();
|
|
916
|
+
done.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
|
|
917
|
+
done.truncate(5);
|
|
918
|
+
done
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
if !completed.is_empty() {
|
|
922
|
+
println!("Recently completed:");
|
|
923
|
+
for item in &completed {
|
|
924
|
+
println!(
|
|
925
|
+
" ✅ {} #{}: {}",
|
|
926
|
+
type_icon(&item.item_type),
|
|
927
|
+
item.id,
|
|
928
|
+
item.title
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
println!();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
println!("Backlog:");
|
|
935
|
+
for item in &top_level {
|
|
936
|
+
let mode_str = item
|
|
937
|
+
.mode
|
|
938
|
+
.as_ref()
|
|
939
|
+
.map(|m| format!(" ({m})"))
|
|
940
|
+
.unwrap_or_default();
|
|
941
|
+
println!(
|
|
942
|
+
"{} #{}: {} [{}]{}",
|
|
943
|
+
type_icon(&item.item_type),
|
|
944
|
+
item.id,
|
|
945
|
+
item.title,
|
|
946
|
+
item.status,
|
|
947
|
+
mode_str
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
// Show children.
|
|
951
|
+
let children: Vec<_> = items.iter().filter(|c| c.parent_id == Some(item.id)).collect();
|
|
952
|
+
for child in &children {
|
|
953
|
+
let child_mode = child
|
|
954
|
+
.mode
|
|
955
|
+
.as_ref()
|
|
956
|
+
.map(|m| format!(" ({m})"))
|
|
957
|
+
.unwrap_or_default();
|
|
958
|
+
println!(
|
|
959
|
+
" {} #{}: {} [{}]{}",
|
|
960
|
+
type_icon(&child.item_type),
|
|
961
|
+
child.id,
|
|
962
|
+
child.title,
|
|
963
|
+
child.status,
|
|
964
|
+
child_mode
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
if expand_all {
|
|
968
|
+
// Show grandchildren.
|
|
969
|
+
let grandchildren: Vec<_> =
|
|
970
|
+
items.iter().filter(|g| g.parent_id == Some(child.id)).collect();
|
|
971
|
+
for gc in &grandchildren {
|
|
972
|
+
println!(
|
|
973
|
+
" {} #{}: {} [{}]",
|
|
974
|
+
type_icon(&gc.item_type),
|
|
975
|
+
gc.id,
|
|
976
|
+
gc.title,
|
|
977
|
+
gc.status
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
Ok(())
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ---------------------------------------------------------------------------
|
|
988
|
+
// Decisions
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
pub fn decisions(
|
|
992
|
+
ctx: &CommandContext,
|
|
993
|
+
all: bool,
|
|
994
|
+
_project: bool,
|
|
995
|
+
_epics: bool,
|
|
996
|
+
epic: Option<i64>,
|
|
997
|
+
view: bool,
|
|
998
|
+
) -> Result<()> {
|
|
999
|
+
if view {
|
|
1000
|
+
let decisions_path = ctx.root.join("docs").join("DECISIONS.md");
|
|
1001
|
+
if decisions_path.exists() {
|
|
1002
|
+
let content = std::fs::read_to_string(&decisions_path)?;
|
|
1003
|
+
println!("{content}");
|
|
1004
|
+
} else {
|
|
1005
|
+
println!("No DECISIONS.md found.");
|
|
1006
|
+
}
|
|
1007
|
+
return Ok(());
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if let Some(epic_id) = epic {
|
|
1011
|
+
let decisions = work::get_decisions(&ctx.db, epic_id)?;
|
|
1012
|
+
let item = work::get(&ctx.db, epic_id)?;
|
|
1013
|
+
println!("Decisions for {} #{}: {}", type_icon(&item.item_type), epic_id, item.title);
|
|
1014
|
+
if decisions.is_empty() {
|
|
1015
|
+
println!(" No decisions recorded.");
|
|
1016
|
+
} else {
|
|
1017
|
+
for d in &decisions {
|
|
1018
|
+
println!(" • {}: {}", d.aspect, d.decision);
|
|
1019
|
+
println!(" Rationale: {}", d.rationale);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return Ok(());
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if all {
|
|
1026
|
+
// Show all decisions across all epics.
|
|
1027
|
+
let items = work::get_tree(&ctx.db, true)?;
|
|
1028
|
+
let epics: Vec<_> = items.iter().filter(|i| i.item_type == ItemType::Epic).collect();
|
|
1029
|
+
for ep in &epics {
|
|
1030
|
+
let decisions = work::get_decisions(&ctx.db, ep.id)?;
|
|
1031
|
+
if !decisions.is_empty() {
|
|
1032
|
+
println!("📦 #{}: {}", ep.id, ep.title);
|
|
1033
|
+
for d in &decisions {
|
|
1034
|
+
println!(" • {}: {}", d.aspect, d.decision);
|
|
1035
|
+
println!(" Rationale: {}", d.rationale);
|
|
1036
|
+
}
|
|
1037
|
+
println!();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
Ok(())
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ---------------------------------------------------------------------------
|
|
1046
|
+
// Project
|
|
1047
|
+
// ---------------------------------------------------------------------------
|
|
1048
|
+
|
|
1049
|
+
pub fn project_state(ctx: &CommandContext) -> Result<()> {
|
|
1050
|
+
let cfg = config::read(&ctx.root);
|
|
1051
|
+
println!("Project state: {}", cfg.project_state);
|
|
1052
|
+
Ok(())
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
pub fn project_info(ctx: &CommandContext) -> Result<()> {
|
|
1056
|
+
let cfg = config::read(&ctx.root);
|
|
1057
|
+
println!("Project: {}", cfg.name);
|
|
1058
|
+
println!("State: {}", cfg.project_state);
|
|
1059
|
+
if let Some(desc) = cfg.extra.get("description") {
|
|
1060
|
+
if let Some(s) = desc.as_str() {
|
|
1061
|
+
println!("Description: {s}");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
Ok(())
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
pub fn project_external(ctx: &CommandContext) -> Result<()> {
|
|
1068
|
+
let mut cfg = config::read(&ctx.root);
|
|
1069
|
+
cfg.project_state = "external".into();
|
|
1070
|
+
config::write(&ctx.root, &cfg)?;
|
|
1071
|
+
println!("✅ Project state updated to: external");
|
|
1072
|
+
Ok(())
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
pub fn project_discover_start(ctx: &CommandContext) -> Result<()> {
|
|
1076
|
+
let mut cfg = config::read(&ctx.root);
|
|
1077
|
+
cfg.project_discovery.status = "in_progress".into();
|
|
1078
|
+
cfg.project_discovery.started_date = Some(chrono::Utc::now().to_rfc3339());
|
|
1079
|
+
config::write(&ctx.root, &cfg)?;
|
|
1080
|
+
println!("🔍 Project discovery started.");
|
|
1081
|
+
Ok(())
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
pub fn project_discover_complete(
|
|
1085
|
+
ctx: &CommandContext,
|
|
1086
|
+
winner: &str,
|
|
1087
|
+
rationale: &str,
|
|
1088
|
+
_prototypes: Option<String>,
|
|
1089
|
+
) -> Result<()> {
|
|
1090
|
+
if !std::path::Path::new(winner).exists() {
|
|
1091
|
+
bail!("Winner path does not exist: {winner}");
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let mut cfg = config::read(&ctx.root);
|
|
1095
|
+
cfg.project_discovery.status = "completed".into();
|
|
1096
|
+
cfg.project_discovery.winner = Some(winner.to_string());
|
|
1097
|
+
cfg.project_discovery.rationale = Some(rationale.to_string());
|
|
1098
|
+
cfg.project_discovery.completed_date = Some(chrono::Utc::now().to_rfc3339());
|
|
1099
|
+
config::write(&ctx.root, &cfg)?;
|
|
1100
|
+
println!("✅ Project discovery completed.");
|
|
1101
|
+
println!(" Winner: {winner}");
|
|
1102
|
+
println!(" Rationale: {rationale}");
|
|
1103
|
+
Ok(())
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
pub fn project_prototype_start(ctx: &CommandContext, approach: &str) -> Result<()> {
|
|
1107
|
+
// Create a special worktree for project prototype.
|
|
1108
|
+
let wt = worktree::create(
|
|
1109
|
+
&ctx.db,
|
|
1110
|
+
&worktree::CreateWorktreeOpts {
|
|
1111
|
+
work_item_id: 0, // Project-level, no work item.
|
|
1112
|
+
title: &format!("project-prototype-{approach}"),
|
|
1113
|
+
repo_root: &ctx.root,
|
|
1114
|
+
},
|
|
1115
|
+
)?;
|
|
1116
|
+
println!("✅ Prototype worktree created: {}", wt.worktree_path);
|
|
1117
|
+
Ok(())
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
pub fn project_prototype_merge(ctx: &CommandContext) -> Result<()> {
|
|
1121
|
+
println!("Merging project prototype...");
|
|
1122
|
+
// Find and merge the project prototype worktree.
|
|
1123
|
+
let active = worktree::get_all_active(&ctx.db)?;
|
|
1124
|
+
let proto = active.iter().find(|w| w.worktree_path.contains("project-prototype"));
|
|
1125
|
+
match proto {
|
|
1126
|
+
Some(wt) => {
|
|
1127
|
+
work_merge(ctx, Some(wt.work_item_id), false)
|
|
1128
|
+
}
|
|
1129
|
+
None => {
|
|
1130
|
+
bail!("No active project prototype worktree found.");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---------------------------------------------------------------------------
|
|
1136
|
+
// Impact
|
|
1137
|
+
// ---------------------------------------------------------------------------
|
|
1138
|
+
|
|
1139
|
+
pub fn impact(ctx: &CommandContext, file: &str) -> Result<()> {
|
|
1140
|
+
let items = work::get_tree(&ctx.db, false)?;
|
|
1141
|
+
|
|
1142
|
+
// Check which features have scenario files that reference this file.
|
|
1143
|
+
let mut affected = Vec::new();
|
|
1144
|
+
for item in &items {
|
|
1145
|
+
if let Some(ref scenario) = item.scenario_file {
|
|
1146
|
+
let scenario_path = ctx.root.join(scenario);
|
|
1147
|
+
if scenario_path.exists() {
|
|
1148
|
+
if let Ok(content) = std::fs::read_to_string(&scenario_path) {
|
|
1149
|
+
if content.contains(file) {
|
|
1150
|
+
affected.push(item);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if affected.is_empty() {
|
|
1158
|
+
println!("No features/tests reference: {file}");
|
|
1159
|
+
} else {
|
|
1160
|
+
println!("Affected by changes to {file}:");
|
|
1161
|
+
for item in &affected {
|
|
1162
|
+
println!(
|
|
1163
|
+
" {} #{}: {} [{}]",
|
|
1164
|
+
type_icon(&item.item_type),
|
|
1165
|
+
item.id,
|
|
1166
|
+
item.title,
|
|
1167
|
+
item.status
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
Ok(())
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
// Workflow
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
|
|
1179
|
+
pub fn workflow_start(ctx: &CommandContext, skill: &str, id: i64) -> Result<()> {
|
|
1180
|
+
// Record skill execution.
|
|
1181
|
+
ctx.db.conn().execute(
|
|
1182
|
+
"INSERT INTO skill_executions (work_item_id, skill_name, status) VALUES (?, ?, 'in_progress')",
|
|
1183
|
+
rusqlite::params![id, skill],
|
|
1184
|
+
)?;
|
|
1185
|
+
|
|
1186
|
+
// Set workflow gate.
|
|
1187
|
+
let gate_name = format!("{}_started", skill.replace('-', "_"));
|
|
1188
|
+
ctx.db.conn().execute(
|
|
1189
|
+
"INSERT OR REPLACE INTO workflow_gates (work_item_id, gate_name, passed_at) \
|
|
1190
|
+
VALUES (?, ?, datetime('now'))",
|
|
1191
|
+
rusqlite::params![id, gate_name],
|
|
1192
|
+
)?;
|
|
1193
|
+
|
|
1194
|
+
println!("Started {} for #{}", skill, id);
|
|
1195
|
+
Ok(())
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
pub fn workflow_complete(ctx: &CommandContext, skill: &str, id: i64) -> Result<()> {
|
|
1199
|
+
// Update skill execution.
|
|
1200
|
+
ctx.db.conn().execute(
|
|
1201
|
+
"UPDATE skill_executions SET status = 'completed', completed_at = datetime('now') \
|
|
1202
|
+
WHERE work_item_id = ? AND skill_name = ? AND status = 'in_progress'",
|
|
1203
|
+
rusqlite::params![id, skill],
|
|
1204
|
+
)?;
|
|
1205
|
+
|
|
1206
|
+
// Set completion gate.
|
|
1207
|
+
let gate_name = format!("{}_complete", skill.replace('-', "_"));
|
|
1208
|
+
ctx.db.conn().execute(
|
|
1209
|
+
"INSERT OR REPLACE INTO workflow_gates (work_item_id, gate_name, passed_at) \
|
|
1210
|
+
VALUES (?, ?, datetime('now'))",
|
|
1211
|
+
rusqlite::params![id, gate_name],
|
|
1212
|
+
)?;
|
|
1213
|
+
|
|
1214
|
+
println!("Completed {} for #{}", skill, id);
|
|
1215
|
+
Ok(())
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
pub fn workflow_checkpoint(ctx: &CommandContext, id: i64, step: i64) -> Result<()> {
|
|
1219
|
+
let branch = git::current_branch(&ctx.root)?.unwrap_or_else(|| "unknown".to_string());
|
|
1220
|
+
|
|
1221
|
+
ctx.db.conn().execute(
|
|
1222
|
+
"INSERT OR REPLACE INTO workflow_checkpoints (work_item_id, current_step, branch_name, skill_name) \
|
|
1223
|
+
VALUES (?, ?, ?, 'chore-mode')",
|
|
1224
|
+
rusqlite::params![id, step, branch],
|
|
1225
|
+
)?;
|
|
1226
|
+
|
|
1227
|
+
println!("Checkpoint: step {} for #{}", step, id);
|
|
1228
|
+
Ok(())
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ---------------------------------------------------------------------------
|
|
1232
|
+
// Dashboard
|
|
1233
|
+
// ---------------------------------------------------------------------------
|
|
1234
|
+
|
|
1235
|
+
pub async fn launch_dashboard(ctx: &CommandContext) -> Result<()> {
|
|
1236
|
+
println!("Starting JettyPod dashboard...");
|
|
1237
|
+
|
|
1238
|
+
// Start WebSocket server for live updates.
|
|
1239
|
+
let config = jettypod_core::ws::WsConfig {
|
|
1240
|
+
port: jettypod_core::ws::DEFAULT_PORT,
|
|
1241
|
+
project_root: ctx.root.clone(),
|
|
1242
|
+
};
|
|
1243
|
+
let _ws_handle = jettypod_core::ws::spawn_server(config);
|
|
1244
|
+
|
|
1245
|
+
println!("WebSocket server started on port {}", jettypod_core::ws::DEFAULT_PORT);
|
|
1246
|
+
println!("Dashboard launch requires the Next.js dev server (not yet ported to Rust).");
|
|
1247
|
+
|
|
1248
|
+
Ok(())
|
|
1249
|
+
}
|