@relipa/ai-flow-kit 0.0.5-beta.1 → 0.0.6-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const { confirm } = require('@inquirer/prompts');
5
+
6
+ const PROJECT_DIR = process.cwd();
7
+ const AIFLOW_DIR = path.join(PROJECT_DIR, '.aiflow');
8
+ const CONTEXT_DIR = path.join(AIFLOW_DIR, 'context');
9
+ const CURRENT_FILE = path.join(CONTEXT_DIR, 'current.json');
10
+ const TASKS_DIR = path.join(AIFLOW_DIR, 'tasks');
11
+
12
+ // ──────────────────────────────────────────────────────────────
13
+ // Entry point
14
+ // ──────────────────────────────────────────────────────────────
15
+
16
+ module.exports = async function task(action, options = {}) {
17
+ try {
18
+ if (action === 'list') return await listTasks();
19
+ if (action === 'status') return await showStatus();
20
+ if (action === 'pause') return await pauseTask(options.note);
21
+ if (action === 'switch' && options.taskId) return await switchTask(options.taskId);
22
+ if (action === 'resume' && options.taskId) return await resumeTask(options.taskId);
23
+ if (action === 'reset' && options.taskId) return await resetTask(options.taskId);
24
+ if (action === 'remove' && options.taskId) return await removeTask(options.taskId);
25
+
26
+ console.log(chalk.yellow('Usage:'));
27
+ console.log(chalk.gray(' aiflow task status — show active task and pending list'));
28
+ console.log(chalk.gray(' aiflow task list — list all saved tasks'));
29
+ console.log(chalk.gray(' aiflow task pause — pause current task'));
30
+ console.log(chalk.gray(' aiflow task pause --note "..." — pause with a note'));
31
+ console.log(chalk.gray(' aiflow task switch <ticket-id> — pause current + switch to another task'));
32
+ console.log(chalk.gray(' aiflow task resume <ticket-id> — resume a paused task'));
33
+ console.log(chalk.gray(' aiflow task reset <ticket-id> — reset task to Gate 1 (keeps context)'));
34
+ console.log(chalk.gray(' aiflow task remove <ticket-id> — permanently delete all task data'));
35
+ } catch (err) {
36
+ console.error(chalk.red(`Error: ${err.message}`));
37
+ }
38
+ };
39
+
40
+ // ──────────────────────────────────────────────────────────────
41
+ // Commands
42
+ // ──────────────────────────────────────────────────────────────
43
+
44
+ async function showStatus() {
45
+ const tasks = await loadAllTaskStates();
46
+ const active = tasks.filter(t => t.status === 'active');
47
+ const pending = tasks.filter(t => t.status === 'pending');
48
+
49
+ console.log(chalk.cyan('\nTask Status\n'));
50
+
51
+ if (active.length === 0) {
52
+ console.log(chalk.gray(' No active task. Run `aiflow use <ticket>` to start one.'));
53
+ } else {
54
+ const t = active[0];
55
+ console.log(` ${chalk.green('● Active')} ${chalk.white(t.taskId)} ${chalk.gray('Gate ' + t.currentGate)} ${chalk.gray(t.title.substring(0, 55))}`);
56
+ }
57
+
58
+ if (pending.length > 0) {
59
+ console.log(chalk.yellow(`\n Pending (${pending.length}):`));
60
+ for (const t of pending) {
61
+ const paused = t.pausedAt ? chalk.gray(` paused ${formatRelative(t.pausedAt)}`) : '';
62
+ const note = t.notes ? chalk.gray(` — ${t.notes.substring(0, 40)}`) : '';
63
+ console.log(` ${chalk.gray('○ Pending')} ${chalk.white(t.taskId.padEnd(14))} Gate ${t.currentGate} ${chalk.gray(t.title.substring(0, 40))}${paused}${note}`);
64
+ }
65
+ console.log(chalk.gray('\n Resume with: aiflow task resume <ticket-id>'));
66
+ }
67
+ console.log();
68
+ }
69
+
70
+ async function listTasks() {
71
+ const tasks = await loadAllTaskStates();
72
+ if (tasks.length === 0) {
73
+ console.log(chalk.gray('No saved tasks.'));
74
+ return;
75
+ }
76
+ console.log(chalk.cyan('\nAll Tasks:\n'));
77
+ for (const t of tasks) {
78
+ const icon = t.status === 'active' ? chalk.green('●') : chalk.gray('○');
79
+ const label = t.status === 'active' ? chalk.green('active ') : chalk.yellow('pending');
80
+ console.log(` ${icon} ${label} ${chalk.white(t.taskId.padEnd(16))} Gate ${t.currentGate} ${chalk.gray(t.title.substring(0, 50))}`);
81
+ }
82
+ console.log();
83
+ }
84
+
85
+ async function pauseTask(note = '') {
86
+ if (!(await fs.pathExists(CURRENT_FILE))) {
87
+ console.log(chalk.yellow('No active task to pause.'));
88
+ return;
89
+ }
90
+
91
+ const ctx = await fs.readJson(CURRENT_FILE).catch(() => null);
92
+ if (!ctx || !ctx.taskId) {
93
+ console.log(chalk.yellow('Current context has no taskId — cannot pause.'));
94
+ return;
95
+ }
96
+
97
+ const taskId = ctx.taskId;
98
+ const taskDir = path.join(TASKS_DIR, taskId);
99
+ await fs.ensureDir(taskDir);
100
+
101
+ // Save context snapshot
102
+ await fs.writeJson(path.join(taskDir, 'context.json'), ctx, { spaces: 2 });
103
+
104
+ // Detect gate from plan/ artifacts
105
+ const currentGate = await detectCurrentGate(taskId);
106
+
107
+ // Write / update task-state.json
108
+ const statePath = path.join(taskDir, 'task-state.json');
109
+ const existing = (await fs.pathExists(statePath)) ? await fs.readJson(statePath).catch(() => ({})) : {};
110
+ const now = new Date().toISOString();
111
+
112
+ const taskState = {
113
+ ...existing,
114
+ taskId,
115
+ title: ctx.title || '',
116
+ status: 'pending',
117
+ createdAt: existing.createdAt || now,
118
+ updatedAt: now,
119
+ pausedAt: now,
120
+ currentGate,
121
+ gateApprovals: existing.gateApprovals || {},
122
+ notes: note || existing.notes || '',
123
+ };
124
+ await fs.writeJson(statePath, taskState, { spaces: 2 });
125
+
126
+ // Remove current.json so no task is active
127
+ await fs.remove(CURRENT_FILE);
128
+
129
+ console.log(chalk.green(`✓ Task ${taskId} paused at Gate ${currentGate}.`));
130
+ if (note) console.log(chalk.gray(` Note: ${note}`));
131
+ console.log(chalk.gray(` Resume later: aiflow task resume ${taskId}`));
132
+ }
133
+
134
+ async function switchTask(targetId) {
135
+ // Pause current task if one is active
136
+ if (await fs.pathExists(CURRENT_FILE)) {
137
+ const ctx = await fs.readJson(CURRENT_FILE).catch(() => null);
138
+ if (ctx && ctx.taskId && ctx.taskId !== targetId) {
139
+ console.log(chalk.gray(` Pausing current task: ${ctx.taskId}`));
140
+ await pauseTask();
141
+ }
142
+ }
143
+
144
+ // Load target task if it exists
145
+ const statePath = path.join(TASKS_DIR, targetId, 'task-state.json');
146
+ if (await fs.pathExists(statePath)) {
147
+ await resumeTask(targetId);
148
+ } else {
149
+ console.log(chalk.yellow(`Task ${targetId} not found in saved tasks.`));
150
+ console.log(chalk.gray(` Run 'aiflow use ${targetId}' to load it from your ticket system.`));
151
+ }
152
+ }
153
+
154
+ async function resumeTask(taskId) {
155
+ const taskDir = path.join(TASKS_DIR, taskId);
156
+ const statePath = path.join(taskDir, 'task-state.json');
157
+ const ctxPath = path.join(taskDir, 'context.json');
158
+
159
+ if (!(await fs.pathExists(statePath))) {
160
+ console.log(chalk.red(`Task not found: ${taskId}`));
161
+ console.log(chalk.gray(' Run `aiflow task list` to see available tasks.'));
162
+ return;
163
+ }
164
+
165
+ const taskState = await fs.readJson(statePath);
166
+ const ctx = await fs.readJson(ctxPath).catch(() => null);
167
+
168
+ if (!ctx) {
169
+ console.log(chalk.red(`Context snapshot missing for task: ${taskId}`));
170
+ console.log(chalk.gray(` Try loading it fresh: aiflow use ${taskId}`));
171
+ return;
172
+ }
173
+
174
+ // Restore context as current
175
+ await fs.ensureDir(CONTEXT_DIR);
176
+ await fs.writeJson(CURRENT_FILE, ctx, { spaces: 2 });
177
+
178
+ // Mark as active
179
+ const now = new Date().toISOString();
180
+ taskState.status = 'active';
181
+ taskState.pausedAt = null;
182
+ taskState.updatedAt = now;
183
+ await fs.writeJson(statePath, taskState, { spaces: 2 });
184
+
185
+ console.log(chalk.green(`✓ Resumed task: ${taskId}`));
186
+ console.log(` ${chalk.white('Gate:')} ${taskState.currentGate} (${gateLabel(taskState.currentGate)})`);
187
+ console.log(` ${chalk.white('Title:')} ${ctx.title.substring(0, 70)}`);
188
+ if (taskState.notes) console.log(` ${chalk.white('Note:')} ${taskState.notes}`);
189
+ console.log(chalk.gray('\n Open Claude to resume: claude'));
190
+ }
191
+
192
+ async function resetTask(taskId) {
193
+ const taskDir = path.join(TASKS_DIR, taskId);
194
+ const statePath = path.join(taskDir, 'task-state.json');
195
+
196
+ if (!(await fs.pathExists(statePath))) {
197
+ console.log(chalk.red(`Task not found: ${taskId}`));
198
+ console.log(chalk.gray(' Run `aiflow task list` to see available tasks.'));
199
+ return;
200
+ }
201
+
202
+ const taskState = await fs.readJson(statePath);
203
+ const planDir = path.join(PROJECT_DIR, 'plan', taskId);
204
+ const planFiles = [];
205
+ if (await fs.pathExists(planDir)) {
206
+ const entries = await fs.readdir(planDir);
207
+ for (const f of entries) planFiles.push(path.join('plan', taskId, f));
208
+ }
209
+
210
+ // Show what will happen
211
+ console.log(chalk.cyan(`\nReset task: ${taskId}\n`));
212
+ console.log(chalk.white(' The following will be reset:'));
213
+ console.log(chalk.gray(` • .aiflow/tasks/${taskId}/task-state.json`));
214
+ console.log(chalk.gray(` Gate: ${taskState.currentGate} → 1 | Approvals cleared | Status: active`));
215
+
216
+ const deletePlan = planFiles.length > 0 && await confirm({
217
+ message: `Also delete ${planFiles.length} plan file(s) in plan/${taskId}/?`,
218
+ default: false,
219
+ });
220
+
221
+ if (deletePlan) {
222
+ console.log(chalk.gray('\n Plan files to delete:'));
223
+ for (const f of planFiles) console.log(chalk.gray(` • ${f}`));
224
+ }
225
+
226
+ console.log();
227
+ const ok = await confirm({ message: 'Proceed with reset?', default: false });
228
+ if (!ok) { console.log(chalk.gray('Cancelled.')); return; }
229
+
230
+ // Reset task-state.json to Gate 1
231
+ const now = new Date().toISOString();
232
+ await fs.writeJson(statePath, {
233
+ ...taskState,
234
+ status: 'active',
235
+ currentGate: 1,
236
+ gateApprovals: {},
237
+ pausedAt: null,
238
+ updatedAt: now,
239
+ notes: '',
240
+ }, { spaces: 2 });
241
+
242
+ // Restore as current context
243
+ const ctxPath = path.join(taskDir, 'context.json');
244
+ if (await fs.pathExists(ctxPath)) {
245
+ await fs.ensureDir(CONTEXT_DIR);
246
+ await fs.copy(ctxPath, CURRENT_FILE, { overwrite: true });
247
+ }
248
+
249
+ if (deletePlan) await fs.remove(planDir);
250
+
251
+ console.log(chalk.green(`✓ Task ${taskId} reset to Gate 1.`));
252
+ if (deletePlan) console.log(chalk.gray(` Deleted plan/${taskId}/`));
253
+ console.log(chalk.gray(' Open Claude to start Gate 1: claude'));
254
+ }
255
+
256
+ async function removeTask(taskId) {
257
+ const taskDir = path.join(TASKS_DIR, taskId);
258
+ const statePath = path.join(taskDir, 'task-state.json');
259
+
260
+ if (!(await fs.pathExists(statePath))) {
261
+ console.log(chalk.red(`Task not found: ${taskId}`));
262
+ console.log(chalk.gray(' Run `aiflow task list` to see available tasks.'));
263
+ return;
264
+ }
265
+
266
+ const isActive = await fs.pathExists(CURRENT_FILE) &&
267
+ ((await fs.readJson(CURRENT_FILE).catch(() => ({}))).taskId === taskId);
268
+ const planDir = path.join(PROJECT_DIR, 'plan', taskId);
269
+ const hasPlan = await fs.pathExists(planDir);
270
+ const planFiles = hasPlan ? await fs.readdir(planDir) : [];
271
+
272
+ // Show what will be deleted
273
+ console.log(chalk.cyan(`\nRemove task: ${taskId}\n`));
274
+ console.log(chalk.white(' The following will be permanently deleted:'));
275
+ console.log(chalk.gray(` • .aiflow/tasks/${taskId}/ (context snapshot + gate state)`));
276
+ if (isActive) console.log(chalk.gray(` • .aiflow/context/current.json (this is the active task)`));
277
+ if (hasPlan) {
278
+ console.log(chalk.gray(` • plan/${taskId}/ (${planFiles.length} file(s)):`));
279
+ for (const f of planFiles) console.log(chalk.gray(` - plan/${taskId}/${f}`));
280
+ }
281
+
282
+ console.log();
283
+ const ok = await confirm({
284
+ message: chalk.red('This cannot be undone. Proceed?'),
285
+ default: false,
286
+ });
287
+ if (!ok) { console.log(chalk.gray('Cancelled.')); return; }
288
+
289
+ await fs.remove(taskDir);
290
+ if (isActive) await fs.remove(CURRENT_FILE);
291
+ if (hasPlan) await fs.remove(planDir);
292
+
293
+ console.log(chalk.green(`✓ Task ${taskId} removed.`));
294
+ }
295
+
296
+ // ──────────────────────────────────────────────────────────────
297
+ // Internal helpers
298
+ // ──────────────────────────────────────────────────────────────
299
+
300
+ async function loadAllTaskStates() {
301
+ if (!(await fs.pathExists(TASKS_DIR))) return [];
302
+ const entries = await fs.readdir(TASKS_DIR, { withFileTypes: true });
303
+ const states = [];
304
+ for (const entry of entries) {
305
+ if (!entry.isDirectory()) continue;
306
+ const statePath = path.join(TASKS_DIR, entry.name, 'task-state.json');
307
+ if (await fs.pathExists(statePath)) {
308
+ const s = await fs.readJson(statePath).catch(() => null);
309
+ if (s) states.push(s);
310
+ }
311
+ }
312
+ return states.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
313
+ }
314
+
315
+ async function detectCurrentGate(taskId) {
316
+ const planDir = path.join(PROJECT_DIR, 'plan', taskId);
317
+ if (!(await fs.pathExists(planDir))) return 1;
318
+ if (await fs.pathExists(path.join(planDir, 'summary.md'))) return 5;
319
+ if (await fs.pathExists(path.join(planDir, 'plan.md'))) return 3;
320
+ if (await fs.pathExists(path.join(planDir, 'requirement.md'))) return 2;
321
+ return 1;
322
+ }
323
+
324
+ function gateLabel(n) {
325
+ const labels = {
326
+ 1: 'AI Analyze Requirement',
327
+ 2: 'Implementation Plan',
328
+ 3: 'Code Generation',
329
+ 4: 'AI Self-Review',
330
+ 5: 'Peer Review & PR',
331
+ };
332
+ return labels[n] || `Gate ${n}`;
333
+ }
334
+
335
+ function formatRelative(isoStr) {
336
+ const ms = Date.now() - new Date(isoStr).getTime();
337
+ const mins = Math.floor(ms / 60000);
338
+ if (mins < 60) return `${mins}m ago`;
339
+ const hrs = Math.floor(mins / 60);
340
+ if (hrs < 24) return `${hrs}h ago`;
341
+ return `${Math.floor(hrs / 24)}d ago`;
342
+ }
343
+
344
+ // ──────────────────────────────────────────────────────────────
345
+ // Export helper used by session-start hook and use.js
346
+ // ──────────────────────────────────────────────────────────────
347
+
348
+ module.exports.getActiveTaskState = async function getActiveTaskState() {
349
+ if (!(await fs.pathExists(CURRENT_FILE))) return null;
350
+ const ctx = await fs.readJson(CURRENT_FILE).catch(() => null);
351
+ if (!ctx || !ctx.taskId) return null;
352
+ const statePath = path.join(TASKS_DIR, ctx.taskId, 'task-state.json');
353
+ return (await fs.pathExists(statePath)) ? fs.readJson(statePath).catch(() => null) : null;
354
+ };
355
+
356
+ module.exports.createOrActivateTaskState = async function createOrActivateTaskState(ctx) {
357
+ const taskId = ctx.taskId;
358
+ if (!taskId) return;
359
+ const taskDir = path.join(TASKS_DIR, taskId);
360
+ const statePath = path.join(taskDir, 'task-state.json');
361
+ await fs.ensureDir(taskDir);
362
+
363
+ // Save context snapshot
364
+ await fs.writeJson(path.join(taskDir, 'context.json'), ctx, { spaces: 2 });
365
+
366
+ // Detect current gate
367
+ const currentGate = await detectCurrentGate(taskId);
368
+
369
+ const existing = (await fs.pathExists(statePath)) ? await fs.readJson(statePath).catch(() => ({})) : {};
370
+ const now = new Date().toISOString();
371
+ const taskState = {
372
+ ...existing,
373
+ taskId,
374
+ title: ctx.title || '',
375
+ status: 'active',
376
+ createdAt: existing.createdAt || now,
377
+ updatedAt: now,
378
+ pausedAt: null,
379
+ currentGate: existing.currentGate || currentGate,
380
+ gateApprovals: existing.gateApprovals || {},
381
+ notes: existing.notes || '',
382
+ };
383
+ await fs.writeJson(statePath, taskState, { spaces: 2 });
384
+ };