tmux-ide 1.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
package/src/task.ts
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { outputError } from "./lib/output.ts";
|
|
3
|
+
import type { ProofSchema } from "./types.ts";
|
|
4
|
+
import {
|
|
5
|
+
ensureTasksDir,
|
|
6
|
+
loadMission,
|
|
7
|
+
saveMission,
|
|
8
|
+
clearMission,
|
|
9
|
+
loadGoals,
|
|
10
|
+
loadGoal,
|
|
11
|
+
saveGoal,
|
|
12
|
+
deleteGoal,
|
|
13
|
+
nextGoalId,
|
|
14
|
+
loadTasks,
|
|
15
|
+
loadTask,
|
|
16
|
+
saveTask,
|
|
17
|
+
deleteTask,
|
|
18
|
+
nextTaskId,
|
|
19
|
+
loadTasksForGoal,
|
|
20
|
+
detectCycle,
|
|
21
|
+
type Mission,
|
|
22
|
+
type Goal,
|
|
23
|
+
type Task,
|
|
24
|
+
} from "./lib/task-store.ts";
|
|
25
|
+
import { isGhAvailable, createTaskPr } from "./lib/github-pr.ts";
|
|
26
|
+
import {
|
|
27
|
+
loadValidationContract,
|
|
28
|
+
loadValidationState,
|
|
29
|
+
saveValidationState,
|
|
30
|
+
checkCoverage,
|
|
31
|
+
parseAssertionIds,
|
|
32
|
+
} from "./lib/validation.ts";
|
|
33
|
+
import { getSessionName, readConfig } from "./lib/yaml-io.ts";
|
|
34
|
+
import { getSessionState } from "./lib/tmux.ts";
|
|
35
|
+
import { listSessionPanes } from "./widgets/lib/pane-comms.ts";
|
|
36
|
+
import { dispatchResearch, loadResearchState, type ResearchTrigger } from "./lib/research.ts";
|
|
37
|
+
import { type OrchestratorState } from "./lib/orchestrator.ts";
|
|
38
|
+
|
|
39
|
+
export function parseProof(raw: string, existing: ProofSchema | null): ProofSchema {
|
|
40
|
+
// Try parsing as JSON first
|
|
41
|
+
if (raw.startsWith("{")) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
44
|
+
const proof: ProofSchema = { ...existing };
|
|
45
|
+
if (parsed.tests && typeof parsed.tests === "object") {
|
|
46
|
+
const t = parsed.tests as Record<string, unknown>;
|
|
47
|
+
if (typeof t.passed === "number" && typeof t.total === "number") {
|
|
48
|
+
proof.tests = { passed: t.passed, total: t.total };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (parsed.pr && typeof parsed.pr === "object") {
|
|
52
|
+
const p = parsed.pr as Record<string, unknown>;
|
|
53
|
+
if (typeof p.number === "number") {
|
|
54
|
+
proof.pr = { number: p.number };
|
|
55
|
+
if (typeof p.url === "string") proof.pr.url = p.url;
|
|
56
|
+
if (typeof p.status === "string") proof.pr.status = p.status;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (parsed.ci && typeof parsed.ci === "object") {
|
|
60
|
+
const c = parsed.ci as Record<string, unknown>;
|
|
61
|
+
if (typeof c.status === "string") {
|
|
62
|
+
proof.ci = { status: c.status };
|
|
63
|
+
if (typeof c.url === "string") proof.ci.url = c.url;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof parsed.notes === "string") proof.notes = parsed.notes;
|
|
67
|
+
return proof;
|
|
68
|
+
} catch {
|
|
69
|
+
// Not valid JSON, fall through to treat as plain string
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Plain string → proof.notes (backward compat)
|
|
73
|
+
return { ...existing, notes: raw };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface TaskCommandValues {
|
|
77
|
+
title?: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
acceptance?: string;
|
|
80
|
+
priority?: string;
|
|
81
|
+
status?: string;
|
|
82
|
+
assign?: string;
|
|
83
|
+
goal?: string;
|
|
84
|
+
tags?: string;
|
|
85
|
+
proof?: string;
|
|
86
|
+
depends?: string;
|
|
87
|
+
pr?: boolean;
|
|
88
|
+
specialty?: string;
|
|
89
|
+
milestone?: string;
|
|
90
|
+
fulfills?: string;
|
|
91
|
+
summary?: string;
|
|
92
|
+
sequence?: string;
|
|
93
|
+
evidence?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function taskCommand(
|
|
97
|
+
targetDir: string | undefined,
|
|
98
|
+
{
|
|
99
|
+
json = false,
|
|
100
|
+
action,
|
|
101
|
+
sub,
|
|
102
|
+
args = [],
|
|
103
|
+
values = {},
|
|
104
|
+
}: {
|
|
105
|
+
json?: boolean;
|
|
106
|
+
action: string;
|
|
107
|
+
sub?: string;
|
|
108
|
+
args: string[];
|
|
109
|
+
values?: TaskCommandValues;
|
|
110
|
+
},
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const dir = resolve(targetDir ?? ".");
|
|
113
|
+
|
|
114
|
+
switch (action) {
|
|
115
|
+
case "mission":
|
|
116
|
+
return handleMission(dir, sub, args, values, json);
|
|
117
|
+
case "milestone":
|
|
118
|
+
return handleMilestone(dir, sub, args, values, json);
|
|
119
|
+
case "validate":
|
|
120
|
+
return handleValidation(dir, sub, args, values, json);
|
|
121
|
+
case "research":
|
|
122
|
+
return handleResearch(dir, sub, args, json);
|
|
123
|
+
case "goal":
|
|
124
|
+
return handleGoal(dir, sub, args, values, json);
|
|
125
|
+
case "task":
|
|
126
|
+
return handleTask(dir, sub, args, values, json);
|
|
127
|
+
default:
|
|
128
|
+
outputError(`Unknown action: ${action}`, "USAGE");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function emptyOrchestratorState(): OrchestratorState {
|
|
133
|
+
return {
|
|
134
|
+
lastActivity: new Map(),
|
|
135
|
+
previousTasks: new Map(),
|
|
136
|
+
claimedTasks: new Set(),
|
|
137
|
+
taskClaimTimes: new Map(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleResearch(dir: string, sub: string | undefined, args: string[], json: boolean): void {
|
|
142
|
+
switch (sub) {
|
|
143
|
+
case "status": {
|
|
144
|
+
const researchState = loadResearchState(dir);
|
|
145
|
+
const tasks = loadTasks(dir);
|
|
146
|
+
const activeTask =
|
|
147
|
+
researchState.activeResearchTaskId != null
|
|
148
|
+
? (tasks.find((task) => task.id === researchState.activeResearchTaskId) ?? null)
|
|
149
|
+
: null;
|
|
150
|
+
const recentFindings = tasks
|
|
151
|
+
.filter((task) => task.tags.includes("research") && task.status === "done")
|
|
152
|
+
.sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated))
|
|
153
|
+
.slice(0, 5)
|
|
154
|
+
.map((task) => ({
|
|
155
|
+
id: task.id,
|
|
156
|
+
title: task.title,
|
|
157
|
+
updated: task.updated,
|
|
158
|
+
type: task.tags.find((tag) => tag !== "research") ?? "research",
|
|
159
|
+
summary: task.salientSummary,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const payload = {
|
|
163
|
+
activeTask,
|
|
164
|
+
recentFindings,
|
|
165
|
+
cooldowns: researchState.lastResearchAt,
|
|
166
|
+
missionStartAnalyzed: researchState.missionStartAnalyzed,
|
|
167
|
+
retryWindow: researchState.retryWindow,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (json) {
|
|
171
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
172
|
+
} else {
|
|
173
|
+
console.log(
|
|
174
|
+
`Active research task: ${activeTask ? `${activeTask.id} — ${activeTask.title}` : "none"}`,
|
|
175
|
+
);
|
|
176
|
+
console.log(`Mission start analyzed: ${researchState.missionStartAnalyzed ? "yes" : "no"}`);
|
|
177
|
+
console.log(`Recent findings: ${recentFindings.length}`);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case "trigger": {
|
|
183
|
+
const type = args[0];
|
|
184
|
+
if (!type) outputError("Usage: tmux-ide research trigger <type>", "USAGE");
|
|
185
|
+
|
|
186
|
+
const { name: session } = getSessionName(dir);
|
|
187
|
+
const sessionState = getSessionState(session);
|
|
188
|
+
if (!sessionState.running) {
|
|
189
|
+
outputError(`Session "${session}" is not running`, "SESSION_NOT_FOUND");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const panes = listSessionPanes(session);
|
|
193
|
+
const tasks = loadTasks(dir);
|
|
194
|
+
const researchState = loadResearchState(dir);
|
|
195
|
+
let maxConcurrentAgents = 10;
|
|
196
|
+
let masterPane: string | null = null;
|
|
197
|
+
let researchEnabled = true;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const { config } = readConfig(dir);
|
|
201
|
+
maxConcurrentAgents = config.orchestrator?.max_concurrent_agents ?? 10;
|
|
202
|
+
masterPane = config.orchestrator?.master_pane ?? null;
|
|
203
|
+
researchEnabled = config.orchestrator?.research?.enabled ?? true;
|
|
204
|
+
} catch {
|
|
205
|
+
// Fall back to defaults when ide.yml is unreadable
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const task = dispatchResearch(
|
|
209
|
+
{
|
|
210
|
+
session,
|
|
211
|
+
dir,
|
|
212
|
+
masterPane,
|
|
213
|
+
maxConcurrentAgents,
|
|
214
|
+
research: { enabled: researchEnabled },
|
|
215
|
+
},
|
|
216
|
+
emptyOrchestratorState(),
|
|
217
|
+
researchState,
|
|
218
|
+
tasks,
|
|
219
|
+
panes,
|
|
220
|
+
{
|
|
221
|
+
type,
|
|
222
|
+
reason: `Manual research trigger: ${type}`,
|
|
223
|
+
} satisfies ResearchTrigger,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!task) {
|
|
227
|
+
outputError(`Unable to dispatch research trigger "${type}"`, "CONFLICT");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (json) {
|
|
231
|
+
console.log(JSON.stringify(task, null, 2));
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`Dispatched research task ${task.id}: ${task.title}`);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "help":
|
|
239
|
+
case undefined:
|
|
240
|
+
console.log(`Usage: tmux-ide research <status|trigger>
|
|
241
|
+
|
|
242
|
+
status Show research agent state
|
|
243
|
+
trigger <type> Manually dispatch a research task`);
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
outputError(
|
|
247
|
+
"Usage: tmux-ide research <status|trigger>\nRun: tmux-ide research help",
|
|
248
|
+
"USAGE",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Mission ---
|
|
254
|
+
|
|
255
|
+
function handleMission(
|
|
256
|
+
dir: string,
|
|
257
|
+
sub: string | undefined,
|
|
258
|
+
args: string[],
|
|
259
|
+
values: TaskCommandValues,
|
|
260
|
+
json: boolean,
|
|
261
|
+
): void {
|
|
262
|
+
switch (sub) {
|
|
263
|
+
case "set": {
|
|
264
|
+
const title = args[0];
|
|
265
|
+
if (!title) outputError('Usage: tmux-ide mission set "title"', "USAGE");
|
|
266
|
+
ensureTasksDir(dir);
|
|
267
|
+
const now = new Date().toISOString();
|
|
268
|
+
const mission: Mission = {
|
|
269
|
+
title,
|
|
270
|
+
description: values.description ?? "",
|
|
271
|
+
status: "active",
|
|
272
|
+
branch: null,
|
|
273
|
+
milestones: [],
|
|
274
|
+
created: now,
|
|
275
|
+
updated: now,
|
|
276
|
+
};
|
|
277
|
+
saveMission(dir, mission);
|
|
278
|
+
if (json) {
|
|
279
|
+
console.log(JSON.stringify(mission, null, 2));
|
|
280
|
+
} else {
|
|
281
|
+
console.log(`Mission set: ${title}`);
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "create": {
|
|
286
|
+
const title = args[0];
|
|
287
|
+
if (!title) outputError('Usage: tmux-ide mission create "title" [-d "description"]', "USAGE");
|
|
288
|
+
ensureTasksDir(dir);
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
const mission: Mission = {
|
|
291
|
+
title,
|
|
292
|
+
description: values.description ?? "",
|
|
293
|
+
status: "planning",
|
|
294
|
+
branch: null,
|
|
295
|
+
milestones: [],
|
|
296
|
+
created: now,
|
|
297
|
+
updated: now,
|
|
298
|
+
};
|
|
299
|
+
saveMission(dir, mission);
|
|
300
|
+
if (json) {
|
|
301
|
+
console.log(JSON.stringify(mission, null, 2));
|
|
302
|
+
} else {
|
|
303
|
+
console.log(`Mission created (planning): ${title}`);
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "plan-complete": {
|
|
308
|
+
const mission = loadMission(dir);
|
|
309
|
+
if (!mission) outputError("No mission set", "NOT_FOUND");
|
|
310
|
+
if (mission.status !== "planning") {
|
|
311
|
+
outputError(`Mission is "${mission.status}", expected "planning"`, "CONFLICT");
|
|
312
|
+
}
|
|
313
|
+
mission.status = "active";
|
|
314
|
+
// Normalize milestone activation so plan completion always starts with only
|
|
315
|
+
// the earliest milestone active, even if planning left bad statuses behind.
|
|
316
|
+
const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
|
|
317
|
+
const first = sorted[0] ?? null;
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
for (const milestone of sorted) {
|
|
320
|
+
milestone.status = first && milestone.id === first.id ? "active" : "locked";
|
|
321
|
+
milestone.updated = now;
|
|
322
|
+
}
|
|
323
|
+
mission.updated = now;
|
|
324
|
+
saveMission(dir, mission);
|
|
325
|
+
// Check coverage invariant
|
|
326
|
+
const { unclaimed } = checkCoverage(dir);
|
|
327
|
+
if (json) {
|
|
328
|
+
console.log(JSON.stringify({ ...mission, coverageGaps: unclaimed }, null, 2));
|
|
329
|
+
} else {
|
|
330
|
+
console.log(`Mission "${mission.title}" planning complete — now active`);
|
|
331
|
+
if (first) console.log(` Activated milestone: ${first.id} — ${first.title}`);
|
|
332
|
+
if (unclaimed.length > 0) {
|
|
333
|
+
console.log(
|
|
334
|
+
` Warning: ${unclaimed.length} assertion(s) not claimed by any task: ${unclaimed.join(", ")}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "show": {
|
|
341
|
+
const mission = loadMission(dir);
|
|
342
|
+
if (!mission) outputError('No mission set. Run: tmux-ide mission set "..."', "NOT_FOUND");
|
|
343
|
+
if (json) {
|
|
344
|
+
console.log(JSON.stringify(mission, null, 2));
|
|
345
|
+
} else {
|
|
346
|
+
console.log(`Mission: ${mission.title}`);
|
|
347
|
+
if (mission.description) console.log(` ${mission.description}`);
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "status": {
|
|
352
|
+
const mission = loadMission(dir);
|
|
353
|
+
if (!mission) outputError("No mission set", "NOT_FOUND");
|
|
354
|
+
const tasks = loadTasks(dir);
|
|
355
|
+
const doneMilestones = mission.milestones.filter((m) => m.status === "done").length;
|
|
356
|
+
const totalMilestones = mission.milestones.length;
|
|
357
|
+
const doneTasks = tasks.filter((t) => t.status === "done").length;
|
|
358
|
+
const totalTasks = tasks.length;
|
|
359
|
+
const valState = loadValidationState(dir);
|
|
360
|
+
const assertions = valState ? Object.values(valState.assertions) : [];
|
|
361
|
+
const passingCount = assertions.filter((a) => a.status === "passing").length;
|
|
362
|
+
const failingCount = assertions.filter((a) => a.status === "failing").length;
|
|
363
|
+
const pendingCount = assertions.filter((a) => a.status === "pending").length;
|
|
364
|
+
if (json) {
|
|
365
|
+
console.log(
|
|
366
|
+
JSON.stringify(
|
|
367
|
+
{
|
|
368
|
+
title: mission.title,
|
|
369
|
+
status: mission.status,
|
|
370
|
+
milestones: { done: doneMilestones, total: totalMilestones },
|
|
371
|
+
tasks: { done: doneTasks, total: totalTasks },
|
|
372
|
+
validation: { passing: passingCount, failing: failingCount, pending: pendingCount },
|
|
373
|
+
},
|
|
374
|
+
null,
|
|
375
|
+
2,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
console.log(`Mission: ${mission.title} [${mission.status}]`);
|
|
380
|
+
console.log(` Milestones: ${doneMilestones}/${totalMilestones} done`);
|
|
381
|
+
console.log(` Tasks: ${doneTasks}/${totalTasks} done`);
|
|
382
|
+
if (assertions.length > 0) {
|
|
383
|
+
console.log(
|
|
384
|
+
` Validation: ${passingCount} passing, ${failingCount} failing, ${pendingCount} pending`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case "clear": {
|
|
391
|
+
clearMission(dir);
|
|
392
|
+
if (json) {
|
|
393
|
+
console.log(JSON.stringify({ cleared: true }));
|
|
394
|
+
} else {
|
|
395
|
+
console.log("Mission cleared");
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "help":
|
|
400
|
+
case undefined:
|
|
401
|
+
console.log(`Usage: tmux-ide mission <set|create|show|status|plan-complete|clear>
|
|
402
|
+
|
|
403
|
+
set "title" [-d "description"] Set mission (status: active)
|
|
404
|
+
create "title" [-d "description"] Create mission (status: planning)
|
|
405
|
+
show Show current mission
|
|
406
|
+
status Show mission progress
|
|
407
|
+
plan-complete Signal planning is done
|
|
408
|
+
clear Clear the mission`);
|
|
409
|
+
break;
|
|
410
|
+
default:
|
|
411
|
+
outputError(
|
|
412
|
+
"Usage: tmux-ide mission <set|create|show|status|plan-complete|clear>\nRun: tmux-ide mission help",
|
|
413
|
+
"USAGE",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Milestone ---
|
|
419
|
+
|
|
420
|
+
function handleMilestone(
|
|
421
|
+
dir: string,
|
|
422
|
+
sub: string | undefined,
|
|
423
|
+
args: string[],
|
|
424
|
+
values: TaskCommandValues,
|
|
425
|
+
json: boolean,
|
|
426
|
+
): void {
|
|
427
|
+
switch (sub) {
|
|
428
|
+
case "create": {
|
|
429
|
+
const title = args[0];
|
|
430
|
+
if (!title) outputError('Usage: tmux-ide milestone create "title" --sequence N', "USAGE");
|
|
431
|
+
const mission = loadMission(dir);
|
|
432
|
+
if (!mission)
|
|
433
|
+
outputError('No mission set. Run: tmux-ide mission set "..." first', "NOT_FOUND");
|
|
434
|
+
const sequence = parseInt(values.sequence ?? "0", 10) || mission.milestones.length + 1;
|
|
435
|
+
const id = `M${sequence}`;
|
|
436
|
+
// Check for duplicate ID
|
|
437
|
+
if (mission.milestones.find((m) => m.id === id)) {
|
|
438
|
+
outputError(`Milestone ${id} already exists`, "CONFLICT");
|
|
439
|
+
}
|
|
440
|
+
const now = new Date().toISOString();
|
|
441
|
+
const hasActive = mission.milestones.some(
|
|
442
|
+
(m) => m.status === "active" || m.status === "done",
|
|
443
|
+
);
|
|
444
|
+
const milestone: Mission["milestones"][number] = {
|
|
445
|
+
id,
|
|
446
|
+
title,
|
|
447
|
+
description: values.description ?? "",
|
|
448
|
+
status: hasActive ? "locked" : "active",
|
|
449
|
+
order: sequence,
|
|
450
|
+
created: now,
|
|
451
|
+
updated: now,
|
|
452
|
+
};
|
|
453
|
+
mission.milestones.push(milestone);
|
|
454
|
+
mission.milestones.sort((a, b) => a.order - b.order);
|
|
455
|
+
mission.updated = now;
|
|
456
|
+
saveMission(dir, mission);
|
|
457
|
+
if (json) {
|
|
458
|
+
console.log(JSON.stringify(milestone, null, 2));
|
|
459
|
+
} else {
|
|
460
|
+
console.log(`Created milestone ${id}: ${title} [${milestone.status}]`);
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "list": {
|
|
465
|
+
const mission = loadMission(dir);
|
|
466
|
+
if (!mission) outputError("No mission set", "NOT_FOUND");
|
|
467
|
+
const tasks = loadTasks(dir);
|
|
468
|
+
const milestones = [...mission.milestones].sort((a, b) => a.order - b.order);
|
|
469
|
+
if (json) {
|
|
470
|
+
const data = milestones.map((m) => {
|
|
471
|
+
const mTasks = tasks.filter((t) => t.milestone === m.id);
|
|
472
|
+
const done = mTasks.filter((t) => t.status === "done").length;
|
|
473
|
+
return { ...m, taskCount: mTasks.length, tasksDone: done };
|
|
474
|
+
});
|
|
475
|
+
console.log(JSON.stringify(data, null, 2));
|
|
476
|
+
} else if (milestones.length === 0) {
|
|
477
|
+
console.log('No milestones. Run: tmux-ide milestone create "title" --sequence N');
|
|
478
|
+
} else {
|
|
479
|
+
for (const m of milestones) {
|
|
480
|
+
const mTasks = tasks.filter((t) => t.milestone === m.id);
|
|
481
|
+
const done = mTasks.filter((t) => t.status === "done").length;
|
|
482
|
+
console.log(` ${m.id} [${m.status}] ${m.title} (${done}/${mTasks.length} tasks)`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
case "show": {
|
|
488
|
+
const id = args[0];
|
|
489
|
+
if (!id) outputError("Usage: tmux-ide milestone show <id>", "USAGE");
|
|
490
|
+
const mission = loadMission(dir);
|
|
491
|
+
if (!mission) outputError("No mission set", "NOT_FOUND");
|
|
492
|
+
const milestone = mission.milestones.find((m) => m.id === id);
|
|
493
|
+
if (!milestone) outputError(`Milestone ${id} not found`, "NOT_FOUND");
|
|
494
|
+
const tasks = loadTasks(dir).filter((t) => t.milestone === id);
|
|
495
|
+
if (json) {
|
|
496
|
+
console.log(JSON.stringify({ milestone, tasks }, null, 2));
|
|
497
|
+
} else {
|
|
498
|
+
console.log(`Milestone ${milestone.id}: ${milestone.title}`);
|
|
499
|
+
console.log(` Status: ${milestone.status}`);
|
|
500
|
+
console.log(` Order: ${milestone.order}`);
|
|
501
|
+
if (milestone.description) console.log(` Description: ${milestone.description}`);
|
|
502
|
+
if (tasks.length > 0) {
|
|
503
|
+
console.log(` Tasks:`);
|
|
504
|
+
for (const t of tasks) {
|
|
505
|
+
console.log(` ${t.id} [${t.status}] ${t.title}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case "update": {
|
|
512
|
+
const id = args[0];
|
|
513
|
+
if (!id) outputError("Usage: tmux-ide milestone update <id> --status <status>", "USAGE");
|
|
514
|
+
const mission = loadMission(dir);
|
|
515
|
+
if (!mission) outputError("No mission set", "NOT_FOUND");
|
|
516
|
+
const milestone = mission.milestones.find((m) => m.id === id);
|
|
517
|
+
if (!milestone) outputError(`Milestone ${id} not found`, "NOT_FOUND");
|
|
518
|
+
if (values.status) {
|
|
519
|
+
milestone.status = values.status as typeof milestone.status;
|
|
520
|
+
}
|
|
521
|
+
if (values.description) milestone.description = values.description;
|
|
522
|
+
milestone.updated = new Date().toISOString();
|
|
523
|
+
mission.updated = milestone.updated;
|
|
524
|
+
saveMission(dir, mission);
|
|
525
|
+
if (json) {
|
|
526
|
+
console.log(JSON.stringify(milestone, null, 2));
|
|
527
|
+
} else {
|
|
528
|
+
console.log(`Updated milestone ${id}`);
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "help":
|
|
533
|
+
case undefined:
|
|
534
|
+
console.log(`Usage: tmux-ide milestone <create|list|show|update>
|
|
535
|
+
|
|
536
|
+
create "title" [--sequence N] [-d "desc"] Create a milestone
|
|
537
|
+
list List milestones
|
|
538
|
+
show <id> Show milestone with tasks
|
|
539
|
+
update <id> [--status active|done|locked] Update milestone`);
|
|
540
|
+
break;
|
|
541
|
+
default:
|
|
542
|
+
outputError(
|
|
543
|
+
"Usage: tmux-ide milestone <create|list|show|update>\nRun: tmux-ide milestone help",
|
|
544
|
+
"USAGE",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// --- Validation ---
|
|
550
|
+
|
|
551
|
+
function handleValidation(
|
|
552
|
+
dir: string,
|
|
553
|
+
sub: string | undefined,
|
|
554
|
+
args: string[],
|
|
555
|
+
values: TaskCommandValues,
|
|
556
|
+
json: boolean,
|
|
557
|
+
): void {
|
|
558
|
+
switch (sub) {
|
|
559
|
+
case "show": {
|
|
560
|
+
const contract = loadValidationContract(dir);
|
|
561
|
+
const state = loadValidationState(dir);
|
|
562
|
+
if (json) {
|
|
563
|
+
console.log(JSON.stringify({ contract: contract ?? null, state: state ?? null }, null, 2));
|
|
564
|
+
} else {
|
|
565
|
+
if (contract) {
|
|
566
|
+
console.log("Validation Contract:");
|
|
567
|
+
console.log(contract);
|
|
568
|
+
console.log();
|
|
569
|
+
} else {
|
|
570
|
+
console.log("No validation contract found (.tasks/validation-contract.md)");
|
|
571
|
+
}
|
|
572
|
+
if (state && Object.keys(state.assertions).length > 0) {
|
|
573
|
+
console.log("Assertion Status:");
|
|
574
|
+
for (const [id, entry] of Object.entries(state.assertions)) {
|
|
575
|
+
const evidence = entry.evidence ? ` — ${entry.evidence}` : "";
|
|
576
|
+
console.log(` ${id} [${entry.status}]${evidence}`);
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
console.log("No assertion state yet.");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case "assert": {
|
|
585
|
+
const assertionId = args[0];
|
|
586
|
+
if (!assertionId || !values.status) {
|
|
587
|
+
outputError(
|
|
588
|
+
'Usage: tmux-ide validate assert <ASSERT_ID> --status passing|failing [--evidence "..."]',
|
|
589
|
+
"USAGE",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const status = values.status as "passing" | "failing" | "blocked";
|
|
593
|
+
if (status !== "passing" && status !== "failing" && status !== "blocked") {
|
|
594
|
+
outputError("Status must be 'passing', 'failing', or 'blocked'", "USAGE");
|
|
595
|
+
}
|
|
596
|
+
ensureTasksDir(dir);
|
|
597
|
+
const state = loadValidationState(dir) ?? { assertions: {}, lastVerified: null };
|
|
598
|
+
state.assertions[assertionId] = {
|
|
599
|
+
status,
|
|
600
|
+
verifiedBy: values.assign ?? null,
|
|
601
|
+
verifiedAt: new Date().toISOString(),
|
|
602
|
+
evidence: values.evidence ?? null,
|
|
603
|
+
blockedBy: status === "blocked" ? (values.evidence ?? null) : null,
|
|
604
|
+
};
|
|
605
|
+
state.lastVerified = new Date().toISOString();
|
|
606
|
+
saveValidationState(dir, state);
|
|
607
|
+
if (json) {
|
|
608
|
+
console.log(JSON.stringify({ assertionId, ...state.assertions[assertionId] }, null, 2));
|
|
609
|
+
} else {
|
|
610
|
+
console.log(
|
|
611
|
+
`Assertion ${assertionId}: ${status}${values.evidence ? ` — ${values.evidence}` : ""}`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
case "report": {
|
|
617
|
+
const state = loadValidationState(dir);
|
|
618
|
+
if (!state || Object.keys(state.assertions).length === 0) {
|
|
619
|
+
if (json) {
|
|
620
|
+
console.log(JSON.stringify({ total: 0, passing: 0, failing: 0, pending: 0, blocked: 0 }));
|
|
621
|
+
} else {
|
|
622
|
+
console.log("No assertions to report.");
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
const entries = Object.values(state.assertions);
|
|
627
|
+
const report = {
|
|
628
|
+
total: entries.length,
|
|
629
|
+
passing: entries.filter((e) => e.status === "passing").length,
|
|
630
|
+
failing: entries.filter((e) => e.status === "failing").length,
|
|
631
|
+
pending: entries.filter((e) => e.status === "pending").length,
|
|
632
|
+
blocked: entries.filter((e) => e.status === "blocked").length,
|
|
633
|
+
};
|
|
634
|
+
if (json) {
|
|
635
|
+
console.log(JSON.stringify(report, null, 2));
|
|
636
|
+
} else {
|
|
637
|
+
console.log(`Validation Report: ${report.total} assertions`);
|
|
638
|
+
console.log(` Passing: ${report.passing}`);
|
|
639
|
+
console.log(` Failing: ${report.failing}`);
|
|
640
|
+
console.log(` Pending: ${report.pending}`);
|
|
641
|
+
if (report.blocked > 0) console.log(` Blocked: ${report.blocked}`);
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
case "coverage": {
|
|
646
|
+
const result = checkCoverage(dir);
|
|
647
|
+
const contract = loadValidationContract(dir);
|
|
648
|
+
const allIds = contract ? parseAssertionIds(contract) : [];
|
|
649
|
+
const claimed = allIds.length - result.unclaimed.length;
|
|
650
|
+
if (json) {
|
|
651
|
+
console.log(
|
|
652
|
+
JSON.stringify(
|
|
653
|
+
{
|
|
654
|
+
total: allIds.length,
|
|
655
|
+
claimed,
|
|
656
|
+
unclaimed: result.unclaimed,
|
|
657
|
+
duplicates: result.duplicates,
|
|
658
|
+
},
|
|
659
|
+
null,
|
|
660
|
+
2,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
} else {
|
|
664
|
+
console.log(`Assertion Coverage: ${claimed}/${allIds.length} claimed`);
|
|
665
|
+
if (result.unclaimed.length > 0) {
|
|
666
|
+
console.log(` Unclaimed: ${result.unclaimed.join(", ")}`);
|
|
667
|
+
}
|
|
668
|
+
if (Object.keys(result.duplicates).length > 0) {
|
|
669
|
+
console.log(` Duplicate claims:`);
|
|
670
|
+
for (const [id, taskIds] of Object.entries(result.duplicates)) {
|
|
671
|
+
console.log(` ${id}: tasks ${taskIds.join(", ")}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (result.unclaimed.length === 0 && Object.keys(result.duplicates).length === 0) {
|
|
675
|
+
console.log(" Full coverage — all assertions claimed.");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
case "help":
|
|
681
|
+
case undefined:
|
|
682
|
+
console.log(`Usage: tmux-ide validate <show|assert|report|coverage>
|
|
683
|
+
|
|
684
|
+
show Show contract + assertion state
|
|
685
|
+
assert <ID> --status passing|failing|blocked [--evidence] Update assertion status
|
|
686
|
+
report Summary of assertion results
|
|
687
|
+
coverage Check assertion coverage`);
|
|
688
|
+
break;
|
|
689
|
+
default:
|
|
690
|
+
outputError(
|
|
691
|
+
"Usage: tmux-ide validate <show|assert|report|coverage>\nRun: tmux-ide validate help",
|
|
692
|
+
"USAGE",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// --- Goal ---
|
|
698
|
+
|
|
699
|
+
function handleGoal(
|
|
700
|
+
dir: string,
|
|
701
|
+
sub: string | undefined,
|
|
702
|
+
args: string[],
|
|
703
|
+
values: TaskCommandValues,
|
|
704
|
+
json: boolean,
|
|
705
|
+
): void {
|
|
706
|
+
switch (sub) {
|
|
707
|
+
case "list": {
|
|
708
|
+
const goals = loadGoals(dir);
|
|
709
|
+
if (json) {
|
|
710
|
+
console.log(JSON.stringify(goals, null, 2));
|
|
711
|
+
} else if (goals.length === 0) {
|
|
712
|
+
console.log('No goals. Run: tmux-ide goal create "..."');
|
|
713
|
+
} else {
|
|
714
|
+
for (const g of goals) {
|
|
715
|
+
const tasks = loadTasksForGoal(dir, g.id);
|
|
716
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
717
|
+
console.log(` ${g.id} [${g.status}] ${g.title} (${done}/${tasks.length} tasks)`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
case "create": {
|
|
723
|
+
const title = args[0];
|
|
724
|
+
if (!title) outputError('Usage: tmux-ide goal create "title"', "USAGE");
|
|
725
|
+
ensureTasksDir(dir);
|
|
726
|
+
const id = nextGoalId(dir);
|
|
727
|
+
const now = new Date().toISOString();
|
|
728
|
+
const goal: Goal = {
|
|
729
|
+
id,
|
|
730
|
+
title,
|
|
731
|
+
description: values.description ?? "",
|
|
732
|
+
status: "todo",
|
|
733
|
+
acceptance: values.acceptance ?? "",
|
|
734
|
+
priority: parseInt(values.priority ?? "2", 10),
|
|
735
|
+
created: now,
|
|
736
|
+
updated: now,
|
|
737
|
+
assignee: null,
|
|
738
|
+
specialty: values.specialty ?? null,
|
|
739
|
+
milestone: values.milestone ?? null,
|
|
740
|
+
};
|
|
741
|
+
saveGoal(dir, goal);
|
|
742
|
+
if (json) {
|
|
743
|
+
console.log(JSON.stringify(goal, null, 2));
|
|
744
|
+
} else {
|
|
745
|
+
console.log(`Created goal ${id}: ${title}`);
|
|
746
|
+
}
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
case "update": {
|
|
750
|
+
const id = args[0];
|
|
751
|
+
if (!id) outputError("Usage: tmux-ide goal update <id>", "USAGE");
|
|
752
|
+
const goal = loadGoal(dir, id);
|
|
753
|
+
if (!goal) outputError(`Goal ${id} not found`, "NOT_FOUND");
|
|
754
|
+
if (values.status) goal.status = values.status as Goal["status"];
|
|
755
|
+
if (values.description) goal.description = values.description;
|
|
756
|
+
if (values.acceptance) goal.acceptance = values.acceptance;
|
|
757
|
+
if (values.priority) goal.priority = parseInt(values.priority, 10);
|
|
758
|
+
if (values.milestone) goal.milestone = values.milestone;
|
|
759
|
+
goal.updated = new Date().toISOString();
|
|
760
|
+
saveGoal(dir, goal);
|
|
761
|
+
if (json) {
|
|
762
|
+
console.log(JSON.stringify(goal, null, 2));
|
|
763
|
+
} else {
|
|
764
|
+
console.log(`Updated goal ${id}`);
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
case "done": {
|
|
769
|
+
const id = args[0];
|
|
770
|
+
if (!id) outputError("Usage: tmux-ide goal done <id>", "USAGE");
|
|
771
|
+
const goal = loadGoal(dir, id);
|
|
772
|
+
if (!goal) outputError(`Goal ${id} not found`, "NOT_FOUND");
|
|
773
|
+
goal.status = "done";
|
|
774
|
+
goal.updated = new Date().toISOString();
|
|
775
|
+
saveGoal(dir, goal);
|
|
776
|
+
if (json) {
|
|
777
|
+
console.log(JSON.stringify(goal, null, 2));
|
|
778
|
+
} else {
|
|
779
|
+
console.log(`Goal ${id} marked done: ${goal.title}`);
|
|
780
|
+
}
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case "show": {
|
|
784
|
+
const id = args[0];
|
|
785
|
+
if (!id) outputError("Usage: tmux-ide goal show <id>", "USAGE");
|
|
786
|
+
const goal = loadGoal(dir, id);
|
|
787
|
+
if (!goal) outputError(`Goal ${id} not found`, "NOT_FOUND");
|
|
788
|
+
const tasks = loadTasksForGoal(dir, id);
|
|
789
|
+
if (json) {
|
|
790
|
+
console.log(JSON.stringify({ goal, tasks }, null, 2));
|
|
791
|
+
} else {
|
|
792
|
+
console.log(`Goal ${goal.id}: ${goal.title}`);
|
|
793
|
+
console.log(` Status: ${goal.status}`);
|
|
794
|
+
if (goal.acceptance) console.log(` Acceptance: ${goal.acceptance}`);
|
|
795
|
+
if (tasks.length > 0) {
|
|
796
|
+
console.log(` Tasks:`);
|
|
797
|
+
for (const t of tasks) {
|
|
798
|
+
console.log(` ${t.id} [${t.status}] ${t.title}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
case "delete": {
|
|
805
|
+
const id = args[0];
|
|
806
|
+
if (!id) outputError("Usage: tmux-ide goal delete <id>", "USAGE");
|
|
807
|
+
if (!deleteGoal(dir, id)) outputError(`Goal ${id} not found`, "NOT_FOUND");
|
|
808
|
+
if (json) {
|
|
809
|
+
console.log(JSON.stringify({ deleted: id }));
|
|
810
|
+
} else {
|
|
811
|
+
console.log(`Deleted goal ${id}`);
|
|
812
|
+
}
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case "help":
|
|
816
|
+
case undefined:
|
|
817
|
+
console.log(`Usage: tmux-ide goal <list|create|update|done|show|delete>
|
|
818
|
+
|
|
819
|
+
list List all goals
|
|
820
|
+
create "title" [-p N] [-d "desc"] Create a goal
|
|
821
|
+
update <id> [-s status] [-p N] Update a goal
|
|
822
|
+
done <id> Mark goal complete
|
|
823
|
+
show <id> Show goal with tasks
|
|
824
|
+
delete <id> Delete a goal`);
|
|
825
|
+
break;
|
|
826
|
+
default:
|
|
827
|
+
outputError(
|
|
828
|
+
"Usage: tmux-ide goal <list|create|update|done|show|delete>\nRun: tmux-ide goal help",
|
|
829
|
+
"USAGE",
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// --- Task ---
|
|
835
|
+
|
|
836
|
+
function handleTask(
|
|
837
|
+
dir: string,
|
|
838
|
+
sub: string | undefined,
|
|
839
|
+
args: string[],
|
|
840
|
+
values: TaskCommandValues,
|
|
841
|
+
json: boolean,
|
|
842
|
+
): void {
|
|
843
|
+
switch (sub) {
|
|
844
|
+
case "init": {
|
|
845
|
+
ensureTasksDir(dir);
|
|
846
|
+
if (json) {
|
|
847
|
+
console.log(JSON.stringify({ initialized: true }));
|
|
848
|
+
} else {
|
|
849
|
+
console.log("Initialized .tasks/ directory");
|
|
850
|
+
}
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
case "list": {
|
|
854
|
+
let tasks = loadTasks(dir);
|
|
855
|
+
const allTasks = tasks; // keep full list for dependency checks
|
|
856
|
+
if (values.status) tasks = tasks.filter((t) => t.status === values.status);
|
|
857
|
+
if (values.goal) tasks = tasks.filter((t) => t.goal === values.goal);
|
|
858
|
+
tasks.sort((a, b) => a.priority - b.priority);
|
|
859
|
+
if (json) {
|
|
860
|
+
console.log(JSON.stringify(tasks, null, 2));
|
|
861
|
+
} else if (tasks.length === 0) {
|
|
862
|
+
console.log('No tasks. Run: tmux-ide task create "title"');
|
|
863
|
+
} else {
|
|
864
|
+
// Build goal name map for display
|
|
865
|
+
const goals = loadGoals(dir);
|
|
866
|
+
const goalNames = new Map(goals.map((g) => [g.id, g.title]));
|
|
867
|
+
|
|
868
|
+
for (const t of tasks) {
|
|
869
|
+
// Check if blocked by unfinished dependencies
|
|
870
|
+
const blocked =
|
|
871
|
+
t.depends_on.length > 0 &&
|
|
872
|
+
t.depends_on.some((depId) => {
|
|
873
|
+
const dep = allTasks.find((d) => d.id === depId);
|
|
874
|
+
return !dep || dep.status !== "done";
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const status = blocked ? "blocked" : t.status;
|
|
878
|
+
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
|
879
|
+
const goalLabel = t.goal ? ` (${goalNames.get(t.goal) ?? `goal ${t.goal}`})` : "";
|
|
880
|
+
const deps = blocked ? ` (depends: ${t.depends_on.join(", ")})` : "";
|
|
881
|
+
console.log(` ${t.id} [${status}] ${t.title}${assignee}${goalLabel}${deps}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
case "create": {
|
|
887
|
+
const title = args[0];
|
|
888
|
+
if (!title)
|
|
889
|
+
outputError(
|
|
890
|
+
'Missing title. Usage:\n tmux-ide task create "Fix the auth bug" -g 01 -p 1 -d "Token storage issue"',
|
|
891
|
+
"USAGE",
|
|
892
|
+
);
|
|
893
|
+
ensureTasksDir(dir);
|
|
894
|
+
const id = nextTaskId(dir);
|
|
895
|
+
const now = new Date().toISOString();
|
|
896
|
+
const task: Task = {
|
|
897
|
+
id,
|
|
898
|
+
title,
|
|
899
|
+
description: values.description ?? "",
|
|
900
|
+
goal: values.goal ?? null,
|
|
901
|
+
status: "todo",
|
|
902
|
+
assignee: values.assign ?? null,
|
|
903
|
+
priority: parseInt(values.priority ?? "2", 10),
|
|
904
|
+
created: now,
|
|
905
|
+
updated: now,
|
|
906
|
+
tags: values.tags ? values.tags.split(",").map((t) => t.trim()) : [],
|
|
907
|
+
proof: null,
|
|
908
|
+
depends_on: values.depends ? values.depends.split(",").map((d) => d.trim()) : [],
|
|
909
|
+
retryCount: 0,
|
|
910
|
+
maxRetries: 5,
|
|
911
|
+
lastError: null,
|
|
912
|
+
nextRetryAt: null,
|
|
913
|
+
milestone: values.milestone ?? null,
|
|
914
|
+
specialty: values.specialty ?? null,
|
|
915
|
+
fulfills: values.fulfills ? values.fulfills.split(",").map((f) => f.trim()) : [],
|
|
916
|
+
discoveredIssues: [],
|
|
917
|
+
salientSummary: null,
|
|
918
|
+
};
|
|
919
|
+
if (task.depends_on.length > 0) {
|
|
920
|
+
const cycle = detectCycle(dir, task.id, task.depends_on);
|
|
921
|
+
if (cycle) {
|
|
922
|
+
outputError(`Dependency cycle detected: ${cycle.join(" -> ")}`, "CYCLE");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
saveTask(dir, task);
|
|
927
|
+
if (json) {
|
|
928
|
+
console.log(JSON.stringify(task, null, 2));
|
|
929
|
+
} else {
|
|
930
|
+
console.log(`Created task ${id}: ${title}`);
|
|
931
|
+
}
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
case "update": {
|
|
935
|
+
const id = args[0];
|
|
936
|
+
if (!id) outputError("Usage: tmux-ide task update <id>", "USAGE");
|
|
937
|
+
const task = loadTask(dir, id);
|
|
938
|
+
if (!task) outputError(`Task ${id} not found`, "NOT_FOUND");
|
|
939
|
+
if (values.status) task.status = values.status as Task["status"];
|
|
940
|
+
if (values.assign) task.assignee = values.assign;
|
|
941
|
+
if (values.description) task.description = values.description;
|
|
942
|
+
if (values.priority) task.priority = parseInt(values.priority, 10);
|
|
943
|
+
if (values.tags) task.tags = values.tags.split(",").map((t) => t.trim());
|
|
944
|
+
if (values.goal) task.goal = values.goal;
|
|
945
|
+
if (values.depends) {
|
|
946
|
+
const newDeps = values.depends.split(",").map((d) => d.trim());
|
|
947
|
+
const cycle = detectCycle(dir, task.id, newDeps);
|
|
948
|
+
if (cycle) {
|
|
949
|
+
outputError(`Dependency cycle detected: ${cycle.join(" -> ")}`, "CYCLE");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
task.depends_on = newDeps;
|
|
953
|
+
}
|
|
954
|
+
if (values.proof) task.proof = parseProof(values.proof, task.proof);
|
|
955
|
+
if (values.milestone) task.milestone = values.milestone;
|
|
956
|
+
if (values.specialty) task.specialty = values.specialty;
|
|
957
|
+
if (values.fulfills) task.fulfills = values.fulfills.split(",").map((f) => f.trim());
|
|
958
|
+
task.updated = new Date().toISOString();
|
|
959
|
+
saveTask(dir, task);
|
|
960
|
+
if (json) {
|
|
961
|
+
console.log(JSON.stringify(task, null, 2));
|
|
962
|
+
} else {
|
|
963
|
+
console.log(`Updated task ${id}`);
|
|
964
|
+
}
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
case "done": {
|
|
968
|
+
const id = args[0];
|
|
969
|
+
if (!id) outputError("Usage: tmux-ide task done <id>", "USAGE");
|
|
970
|
+
const task = loadTask(dir, id);
|
|
971
|
+
if (!task) outputError(`Task ${id} not found`, "NOT_FOUND");
|
|
972
|
+
task.status = "done";
|
|
973
|
+
if (values.proof) task.proof = parseProof(values.proof, task.proof);
|
|
974
|
+
if (values.summary) task.salientSummary = values.summary;
|
|
975
|
+
task.updated = new Date().toISOString();
|
|
976
|
+
|
|
977
|
+
// Auto-create GitHub PR if --pr flag is set
|
|
978
|
+
let prWarning: string | undefined;
|
|
979
|
+
if (values.pr) {
|
|
980
|
+
if (isGhAvailable()) {
|
|
981
|
+
const pr = createTaskPr(task, dir);
|
|
982
|
+
if (pr) {
|
|
983
|
+
if (!task.proof) task.proof = {};
|
|
984
|
+
(task.proof as ProofSchema).pr = {
|
|
985
|
+
number: pr.number,
|
|
986
|
+
url: pr.url,
|
|
987
|
+
status: "open",
|
|
988
|
+
};
|
|
989
|
+
if (!json) console.log(`PR created: ${pr.url}`);
|
|
990
|
+
} else {
|
|
991
|
+
prWarning = "PR creation failed (gh error)";
|
|
992
|
+
console.error(`Warning: ${prWarning}`);
|
|
993
|
+
}
|
|
994
|
+
} else {
|
|
995
|
+
prWarning = "gh CLI not found, skipping PR creation";
|
|
996
|
+
console.error(`Warning: ${prWarning}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
saveTask(dir, task);
|
|
1001
|
+
if (json) {
|
|
1002
|
+
const output: Record<string, unknown> = { ...task };
|
|
1003
|
+
if (prWarning) output.prError = prWarning;
|
|
1004
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1005
|
+
} else {
|
|
1006
|
+
console.log(`Task ${id} done: ${task.title}`);
|
|
1007
|
+
}
|
|
1008
|
+
// Check if all tasks for this goal are done
|
|
1009
|
+
if (task.goal) {
|
|
1010
|
+
const goalTasks = loadTasksForGoal(dir, task.goal);
|
|
1011
|
+
if (goalTasks.every((t) => t.status === "done")) {
|
|
1012
|
+
if (!json) {
|
|
1013
|
+
console.log(
|
|
1014
|
+
`All tasks for goal ${task.goal} are done. Run: tmux-ide goal done ${task.goal}`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
case "claim": {
|
|
1022
|
+
const id = args[0];
|
|
1023
|
+
if (!id) outputError("Usage: tmux-ide task claim <id>", "USAGE");
|
|
1024
|
+
const task = loadTask(dir, id);
|
|
1025
|
+
if (!task) outputError(`Task ${id} not found`, "NOT_FOUND");
|
|
1026
|
+
task.assignee = values.assign ?? null;
|
|
1027
|
+
task.status = "in-progress";
|
|
1028
|
+
task.updated = new Date().toISOString();
|
|
1029
|
+
saveTask(dir, task);
|
|
1030
|
+
if (json) {
|
|
1031
|
+
console.log(JSON.stringify(task, null, 2));
|
|
1032
|
+
} else {
|
|
1033
|
+
const who = task.assignee ? ` by ${task.assignee}` : "";
|
|
1034
|
+
console.log(`Task ${id} claimed${who}: ${task.title}`);
|
|
1035
|
+
}
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
case "show": {
|
|
1039
|
+
const id = args[0];
|
|
1040
|
+
if (!id) outputError("Usage: tmux-ide task show <id>", "USAGE");
|
|
1041
|
+
const task = loadTask(dir, id);
|
|
1042
|
+
if (!task) outputError(`Task ${id} not found. Run: tmux-ide task list`, "NOT_FOUND");
|
|
1043
|
+
const mission = loadMission(dir);
|
|
1044
|
+
const goal = task.goal ? loadGoal(dir, task.goal) : null;
|
|
1045
|
+
if (json) {
|
|
1046
|
+
console.log(
|
|
1047
|
+
JSON.stringify({ mission: mission ?? null, goal: goal ?? null, task }, null, 2),
|
|
1048
|
+
);
|
|
1049
|
+
} else {
|
|
1050
|
+
if (mission) console.log(`Mission: ${mission.title}`);
|
|
1051
|
+
if (goal) {
|
|
1052
|
+
console.log(`Goal ${goal.id}: ${goal.title}`);
|
|
1053
|
+
if (goal.acceptance) console.log(` Acceptance: ${goal.acceptance}`);
|
|
1054
|
+
}
|
|
1055
|
+
console.log(`Task ${task.id}: ${task.title}`);
|
|
1056
|
+
console.log(` Status: ${task.status}`);
|
|
1057
|
+
if (task.description) console.log(` Description: ${task.description}`);
|
|
1058
|
+
if (task.assignee) console.log(` Assignee: ${task.assignee}`);
|
|
1059
|
+
if (task.tags.length > 0) console.log(` Tags: ${task.tags.join(", ")}`);
|
|
1060
|
+
if (task.depends_on.length > 0) console.log(` Depends on: ${task.depends_on.join(", ")}`);
|
|
1061
|
+
if (task.proof) console.log(` Proof: ${JSON.stringify(task.proof)}`);
|
|
1062
|
+
}
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
case "edit": {
|
|
1066
|
+
const id = args[0];
|
|
1067
|
+
if (!id)
|
|
1068
|
+
outputError("Usage: tmux-ide task edit <id> --title 'new title' -d 'new desc'", "USAGE");
|
|
1069
|
+
const task = loadTask(dir, id);
|
|
1070
|
+
if (!task) outputError(`Task ${id} not found. Run: tmux-ide task list`, "NOT_FOUND");
|
|
1071
|
+
if (values.title) task.title = values.title;
|
|
1072
|
+
if (values.description) task.description = values.description;
|
|
1073
|
+
if (values.priority) task.priority = parseInt(values.priority, 10);
|
|
1074
|
+
if (values.tags) task.tags = values.tags.split(",").map((t) => t.trim());
|
|
1075
|
+
if (values.goal) task.goal = values.goal;
|
|
1076
|
+
if (values.depends) {
|
|
1077
|
+
const newDeps = values.depends.split(",").map((d) => d.trim());
|
|
1078
|
+
const cycle = detectCycle(dir, task.id, newDeps);
|
|
1079
|
+
if (cycle) {
|
|
1080
|
+
outputError(`Dependency cycle detected: ${cycle.join(" -> ")}`, "CYCLE");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
task.depends_on = newDeps;
|
|
1084
|
+
}
|
|
1085
|
+
task.updated = new Date().toISOString();
|
|
1086
|
+
saveTask(dir, task);
|
|
1087
|
+
if (json) {
|
|
1088
|
+
console.log(JSON.stringify(task, null, 2));
|
|
1089
|
+
} else {
|
|
1090
|
+
console.log(`Edited task ${id}: ${task.title}`);
|
|
1091
|
+
}
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
case "unassign": {
|
|
1095
|
+
const id = args[0];
|
|
1096
|
+
if (!id) outputError("Usage: tmux-ide task unassign <id>", "USAGE");
|
|
1097
|
+
const task = loadTask(dir, id);
|
|
1098
|
+
if (!task) outputError(`Task ${id} not found. Run: tmux-ide task list`, "NOT_FOUND");
|
|
1099
|
+
task.assignee = null;
|
|
1100
|
+
task.status = "todo";
|
|
1101
|
+
task.updated = new Date().toISOString();
|
|
1102
|
+
saveTask(dir, task);
|
|
1103
|
+
if (json) {
|
|
1104
|
+
console.log(JSON.stringify(task, null, 2));
|
|
1105
|
+
} else {
|
|
1106
|
+
console.log(`Task ${id} unassigned, status reset to todo: ${task.title}`);
|
|
1107
|
+
}
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
case "delete": {
|
|
1111
|
+
const id = args[0];
|
|
1112
|
+
if (!id) outputError("Usage: tmux-ide task delete <id>", "USAGE");
|
|
1113
|
+
if (!deleteTask(dir, id))
|
|
1114
|
+
outputError(`Task ${id} not found. Run: tmux-ide task list`, "NOT_FOUND");
|
|
1115
|
+
if (json) {
|
|
1116
|
+
console.log(JSON.stringify({ deleted: id }));
|
|
1117
|
+
} else {
|
|
1118
|
+
console.log(`Deleted task ${id}`);
|
|
1119
|
+
}
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
case "help":
|
|
1123
|
+
case undefined:
|
|
1124
|
+
console.log(`Usage: tmux-ide task <subcommand>
|
|
1125
|
+
|
|
1126
|
+
init Initialize .tasks/ directory
|
|
1127
|
+
list [-s status] [-g goalId] [--json] List tasks
|
|
1128
|
+
create "title" [-g id] [-p N] [-d ...] Create a task
|
|
1129
|
+
edit <id> [--title ...] [-d ...] [-p N] Edit task fields
|
|
1130
|
+
update <id> [-s status] [-a name] Update task status/assignee
|
|
1131
|
+
done <id> [--proof "..."] Mark task complete
|
|
1132
|
+
claim <id> [-a name] Claim and start a task
|
|
1133
|
+
unassign <id> Unassign and reset to todo
|
|
1134
|
+
show <id> [--json] Show task with full context
|
|
1135
|
+
delete <id> Delete a task
|
|
1136
|
+
|
|
1137
|
+
Short flags: -p priority, -g goal, -d description, -a assign,
|
|
1138
|
+
-s status, -t tags, -b branch`);
|
|
1139
|
+
break;
|
|
1140
|
+
default:
|
|
1141
|
+
outputError(`Unknown subcommand: ${sub}\nRun: tmux-ide task help`, "USAGE");
|
|
1142
|
+
}
|
|
1143
|
+
}
|