@sduck/sduck-cli 0.1.8 → 0.2.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/dist/cli.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/core/archive.ts
7
- import { readFile as readFile2, rename } from "fs/promises";
6
+ // src/core/abandon.ts
7
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
8
8
  import { join as join3 } from "path";
9
9
 
10
10
  // src/core/fs.ts
@@ -34,12 +34,17 @@ async function copyFileIntoPlace(sourcePath, targetPath) {
34
34
  await copyFile(sourcePath, targetPath);
35
35
  }
36
36
 
37
+ // src/core/state.ts
38
+ import { readFile, writeFile } from "fs/promises";
39
+
37
40
  // src/core/project-paths.ts
38
41
  import { join, relative } from "path";
39
42
  var SDUCK_HOME_DIR = ".sduck";
40
43
  var SDUCK_ASSETS_DIR = "sduck-assets";
41
44
  var SDUCK_WORKSPACE_DIR = "sduck-workspace";
42
45
  var SDUCK_ARCHIVE_DIR = "sduck-archive";
46
+ var SDUCK_STATE_FILE = "sduck-state.yml";
47
+ var SDUCK_WORKTREES_DIR = ".sduck-worktrees";
43
48
  var PROJECT_SDUCK_HOME_RELATIVE_PATH = SDUCK_HOME_DIR;
44
49
  var PROJECT_SDUCK_ASSETS_RELATIVE_PATH = join(SDUCK_HOME_DIR, SDUCK_ASSETS_DIR);
45
50
  var PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH = join(SDUCK_HOME_DIR, SDUCK_WORKSPACE_DIR);
@@ -62,19 +67,82 @@ function getProjectRelativeSduckAssetPath(...segments) {
62
67
  function getProjectRelativeSduckWorkspacePath(...segments) {
63
68
  return join(PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH, ...segments);
64
69
  }
70
+ function getProjectSduckStatePath(projectRoot) {
71
+ return join(projectRoot, SDUCK_HOME_DIR, SDUCK_STATE_FILE);
72
+ }
73
+ function getProjectWorktreePath(projectRoot, workId) {
74
+ return join(projectRoot, SDUCK_WORKTREES_DIR, workId);
75
+ }
65
76
  function toBundledAssetRelativePath(projectRelativeAssetPath) {
66
77
  return relative(PROJECT_SDUCK_ASSETS_RELATIVE_PATH, projectRelativeAssetPath);
67
78
  }
68
79
 
80
+ // src/utils/utc-date.ts
81
+ function pad2(value) {
82
+ return String(value).padStart(2, "0");
83
+ }
84
+ function formatUtcDate(date) {
85
+ const year = String(date.getUTCFullYear());
86
+ const month = pad2(date.getUTCMonth() + 1);
87
+ const day = pad2(date.getUTCDate());
88
+ return `${year}-${month}-${day}`;
89
+ }
90
+ function formatUtcTimestamp(date) {
91
+ const year = String(date.getUTCFullYear());
92
+ const month = pad2(date.getUTCMonth() + 1);
93
+ const day = pad2(date.getUTCDate());
94
+ const hour = pad2(date.getUTCHours());
95
+ const minute = pad2(date.getUTCMinutes());
96
+ const second = pad2(date.getUTCSeconds());
97
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
98
+ }
99
+
100
+ // src/core/state.ts
101
+ async function readCurrentWorkId(projectRoot) {
102
+ const statePath = getProjectSduckStatePath(projectRoot);
103
+ if (await getFsEntryKind(statePath) !== "file") {
104
+ return null;
105
+ }
106
+ const content = await readFile(statePath, "utf8");
107
+ const match = /^current_work_id:[ \t]+(.+)$/m.exec(content);
108
+ const value = match?.[1]?.trim();
109
+ if (value === void 0 || value === "null") {
110
+ return null;
111
+ }
112
+ return value;
113
+ }
114
+ async function writeCurrentWorkId(projectRoot, workId, date = /* @__PURE__ */ new Date()) {
115
+ const statePath = getProjectSduckStatePath(projectRoot);
116
+ const idValue = workId ?? "null";
117
+ const content = `current_work_id: ${idValue}
118
+ updated_at: ${formatUtcTimestamp(date)}
119
+ `;
120
+ await writeFile(statePath, content, "utf8");
121
+ }
122
+ function throwNoCurrentWorkError(command) {
123
+ throw new Error(`No current work set. Run \`sduck ${command} <target>\` with explicit target.`);
124
+ }
125
+
69
126
  // src/core/workspace.ts
70
- import { readFile } from "fs/promises";
127
+ import { readFile as readFile2, readdir } from "fs/promises";
71
128
  import { join as join2 } from "path";
72
- var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["IN_PROGRESS", "PENDING_SPEC_APPROVAL", "PENDING_PLAN_APPROVAL"]);
129
+ function setIfNotNull(meta, key, value) {
130
+ if (value !== void 0) {
131
+ const trimmed = value.trim();
132
+ if (trimmed !== "null") {
133
+ meta[key] = trimmed;
134
+ }
135
+ }
136
+ }
73
137
  function parseMetaText(content) {
74
- const createdAtMatch = /^created_at:\s+(.+)$/m.exec(content);
75
- const idMatch = /^id:\s+(.+)$/m.exec(content);
76
- const slugMatch = /^slug:\s+(.+)$/m.exec(content);
77
- const statusMatch = /^status:\s+(.+)$/m.exec(content);
138
+ const createdAtMatch = /^created_at:[ \t]+(.+)$/m.exec(content);
139
+ const idMatch = /^id:[ \t]+(.+)$/m.exec(content);
140
+ const slugMatch = /^slug:[ \t]+(.+)$/m.exec(content);
141
+ const statusMatch = /^status:[ \t]+(.+)$/m.exec(content);
142
+ const branchMatch = /^branch:[ \t]+(.+)$/m.exec(content);
143
+ const baseBranchMatch = /^base_branch:[ \t]+(.+)$/m.exec(content);
144
+ const worktreePathMatch = /^worktree_path:[ \t]+(.+)$/m.exec(content);
145
+ const updatedAtMatch = /^updated_at:[ \t]+(.+)$/m.exec(content);
78
146
  const parsedMeta = {};
79
147
  if (createdAtMatch?.[1] !== void 0) {
80
148
  parsedMeta.createdAt = createdAtMatch[1].trim();
@@ -88,6 +156,10 @@ function parseMetaText(content) {
88
156
  if (statusMatch?.[1] !== void 0) {
89
157
  parsedMeta.status = statusMatch[1].trim();
90
158
  }
159
+ setIfNotNull(parsedMeta, "branch", branchMatch?.[1]);
160
+ setIfNotNull(parsedMeta, "baseBranch", baseBranchMatch?.[1]);
161
+ setIfNotNull(parsedMeta, "worktreePath", worktreePathMatch?.[1]);
162
+ setIfNotNull(parsedMeta, "updatedAt", updatedAtMatch?.[1]);
91
163
  return parsedMeta;
92
164
  }
93
165
  function sortTasksByRecency(tasks) {
@@ -97,58 +169,140 @@ function sortTasksByRecency(tasks) {
97
169
  return rightValue.localeCompare(leftValue);
98
170
  });
99
171
  }
172
+ async function readTaskFromEntry(projectRoot, dirName) {
173
+ const relativePath = getProjectRelativeSduckWorkspacePath(dirName);
174
+ const metaPath = join2(projectRoot, relativePath, "meta.yml");
175
+ if (await getFsEntryKind(metaPath) !== "file") {
176
+ return null;
177
+ }
178
+ const parsedMeta = parseMetaText(await readFile2(metaPath, "utf8"));
179
+ if (parsedMeta.id === void 0 || parsedMeta.status === void 0) {
180
+ return null;
181
+ }
182
+ const task = {
183
+ id: parsedMeta.id,
184
+ path: relativePath,
185
+ status: parsedMeta.status
186
+ };
187
+ if (parsedMeta.createdAt !== void 0) {
188
+ task.createdAt = parsedMeta.createdAt;
189
+ }
190
+ if (parsedMeta.slug !== void 0) {
191
+ task.slug = parsedMeta.slug;
192
+ }
193
+ if (parsedMeta.branch !== void 0) {
194
+ task.branch = parsedMeta.branch;
195
+ }
196
+ if (parsedMeta.baseBranch !== void 0) {
197
+ task.baseBranch = parsedMeta.baseBranch;
198
+ }
199
+ if (parsedMeta.worktreePath !== void 0) {
200
+ task.worktreePath = parsedMeta.worktreePath;
201
+ }
202
+ if (parsedMeta.updatedAt !== void 0) {
203
+ task.updatedAt = parsedMeta.updatedAt;
204
+ }
205
+ return task;
206
+ }
100
207
  async function listWorkspaceTasks(projectRoot) {
101
208
  const workspaceRoot = getProjectSduckWorkspacePath(projectRoot);
102
209
  if (await getFsEntryKind(workspaceRoot) !== "directory") {
103
210
  return [];
104
211
  }
105
- const { readdir } = await import("fs/promises");
106
212
  const entries = await readdir(workspaceRoot, { withFileTypes: true });
213
+ const dirNames = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
214
+ const results = await Promise.allSettled(
215
+ dirNames.map((dirName) => readTaskFromEntry(projectRoot, dirName))
216
+ );
107
217
  const tasks = [];
108
- for (const entry of entries) {
109
- if (!entry.isDirectory()) {
110
- continue;
111
- }
112
- const relativePath = getProjectRelativeSduckWorkspacePath(entry.name);
113
- const metaPath = join2(projectRoot, relativePath, "meta.yml");
114
- if (await getFsEntryKind(metaPath) !== "file") {
115
- continue;
116
- }
117
- const parsedMeta = parseMetaText(await readFile(metaPath, "utf8"));
118
- if (parsedMeta.id !== void 0 && parsedMeta.status !== void 0) {
119
- const task = {
120
- id: parsedMeta.id,
121
- path: relativePath,
122
- status: parsedMeta.status
123
- };
124
- if (parsedMeta.createdAt !== void 0) {
125
- task.createdAt = parsedMeta.createdAt;
126
- }
127
- if (parsedMeta.slug !== void 0) {
128
- task.slug = parsedMeta.slug;
129
- }
130
- tasks.push(task);
218
+ for (const result of results) {
219
+ if (result.status === "fulfilled" && result.value !== null) {
220
+ tasks.push(result.value);
131
221
  }
132
222
  }
133
223
  return sortTasksByRecency(tasks);
134
224
  }
135
- async function findActiveTask(projectRoot) {
225
+
226
+ // src/core/abandon.ts
227
+ var ABANDONABLE_STATUSES = /* @__PURE__ */ new Set([
228
+ "PENDING_SPEC_APPROVAL",
229
+ "PENDING_PLAN_APPROVAL",
230
+ "IN_PROGRESS",
231
+ "REVIEW_READY",
232
+ "SPEC_APPROVED"
233
+ ]);
234
+ async function resolveAbandonTarget(projectRoot, target) {
136
235
  const tasks = await listWorkspaceTasks(projectRoot);
137
- for (const task of tasks) {
138
- if (ACTIVE_STATUSES.has(task.status)) {
139
- return {
140
- id: task.id,
141
- path: task.path,
142
- status: task.status
143
- };
236
+ const trimmed = target.trim();
237
+ const idMatch = tasks.find((task) => task.id === trimmed);
238
+ if (idMatch !== void 0) {
239
+ if (!ABANDONABLE_STATUSES.has(idMatch.status)) {
240
+ throw new Error(
241
+ `Cannot abandon work ${idMatch.id}: status is ${idMatch.status}. Only active works can be abandoned.`
242
+ );
144
243
  }
244
+ return idMatch;
245
+ }
246
+ const slugMatches = tasks.filter((task) => task.slug === trimmed);
247
+ if (slugMatches.length === 1) {
248
+ const match = slugMatches[0];
249
+ if (match === void 0) {
250
+ throw new Error(`No work matches '${trimmed}'.`);
251
+ }
252
+ if (!ABANDONABLE_STATUSES.has(match.status)) {
253
+ throw new Error(
254
+ `Cannot abandon work ${match.id}: status is ${match.status}. Only active works can be abandoned.`
255
+ );
256
+ }
257
+ return match;
258
+ }
259
+ if (slugMatches.length > 1) {
260
+ const candidates = slugMatches.map((task) => task.id).join(", ");
261
+ throw new Error(
262
+ `Multiple works match slug '${trimmed}': ${candidates}. Use \`sduck abandon <id>\` to specify.`
263
+ );
264
+ }
265
+ throw new Error(`No work matches '${trimmed}'.`);
266
+ }
267
+ async function runAbandonWorkflow(projectRoot, target, date = /* @__PURE__ */ new Date()) {
268
+ const work = await resolveAbandonTarget(projectRoot, target);
269
+ const metaPath = join3(projectRoot, work.path, "meta.yml");
270
+ if (await getFsEntryKind(metaPath) !== "file") {
271
+ throw new Error(`Missing meta.yml for work ${work.id}.`);
272
+ }
273
+ const metaContent = await readFile3(metaPath, "utf8");
274
+ const updatedContent = metaContent.replace(/^status:\s+.+$/m, "status: ABANDONED").replace(/^updated_at:\s+.+$/m, `updated_at: ${formatUtcTimestamp(date)}`);
275
+ await writeFile2(metaPath, updatedContent, "utf8");
276
+ const currentWorkId = await readCurrentWorkId(projectRoot);
277
+ if (currentWorkId === work.id) {
278
+ await writeCurrentWorkId(projectRoot, null, date);
279
+ }
280
+ return { workId: work.id };
281
+ }
282
+
283
+ // src/commands/abandon.ts
284
+ async function runAbandonCommand(target, projectRoot) {
285
+ try {
286
+ const { workId } = await runAbandonWorkflow(projectRoot, target);
287
+ return {
288
+ exitCode: 0,
289
+ stderr: "",
290
+ stdout: `\uC791\uC5C5 \uC911\uB2E8\uB428: ${workId}`
291
+ };
292
+ } catch (error) {
293
+ return {
294
+ exitCode: 1,
295
+ stderr: error instanceof Error ? error.message : "Unknown abandon failure.",
296
+ stdout: ""
297
+ };
145
298
  }
146
- return null;
147
299
  }
148
300
 
149
301
  // src/core/archive.ts
302
+ import { readFile as readFile4, rename } from "fs/promises";
303
+ import { join as join4 } from "path";
150
304
  function extractCompletedAt(metaContent) {
151
- const match = /^completed_at:\s+(.+)$/m.exec(metaContent);
305
+ const match = /^completed_at:[ \t]+(.+)$/m.exec(metaContent);
152
306
  const value = match?.[1]?.trim();
153
307
  if (value === void 0 || value === "null") {
154
308
  return null;
@@ -162,7 +316,7 @@ function filterArchiveCandidates(tasks) {
162
316
  return tasks.filter((task) => task.status === "DONE");
163
317
  }
164
318
  async function isAlreadyArchived(archivePath, taskDirName) {
165
- const targetPath = join3(archivePath, taskDirName);
319
+ const targetPath = join4(archivePath, taskDirName);
166
320
  return await getFsEntryKind(targetPath) === "directory";
167
321
  }
168
322
  async function loadArchiveTargets(projectRoot, input) {
@@ -170,8 +324,8 @@ async function loadArchiveTargets(projectRoot, input) {
170
324
  const doneTasks = filterArchiveCandidates(tasks);
171
325
  const targets = [];
172
326
  for (const task of doneTasks) {
173
- const metaPath = join3(projectRoot, task.path, "meta.yml");
174
- const metaContent = await readFile2(metaPath, "utf8");
327
+ const metaPath = join4(projectRoot, task.path, "meta.yml");
328
+ const metaContent = await readFile4(metaPath, "utf8");
175
329
  const completedAt = extractCompletedAt(metaContent);
176
330
  if (completedAt === null) {
177
331
  continue;
@@ -196,7 +350,7 @@ async function runArchiveWorkflow(projectRoot, targets) {
196
350
  const archived = [];
197
351
  const skipped = [];
198
352
  for (const target of targets) {
199
- const monthDir = join3(archiveRoot, target.month);
353
+ const monthDir = join4(archiveRoot, target.month);
200
354
  await ensureDirectory(monthDir);
201
355
  const segments = target.path.split("/");
202
356
  const taskDirName = segments.at(-1) ?? target.id;
@@ -204,8 +358,8 @@ async function runArchiveWorkflow(projectRoot, targets) {
204
358
  skipped.push({ reason: "already archived", taskId: target.id });
205
359
  continue;
206
360
  }
207
- const sourcePath = join3(projectRoot, target.path);
208
- const destPath = join3(monthDir, taskDirName);
361
+ const sourcePath = join4(projectRoot, target.path);
362
+ const destPath = join4(monthDir, taskDirName);
209
363
  await rename(sourcePath, destPath);
210
364
  archived.push({ month: target.month, taskId: target.id });
211
365
  }
@@ -264,34 +418,236 @@ async function runArchiveCommand(input, projectRoot) {
264
418
  }
265
419
  }
266
420
 
267
- // src/core/done.ts
268
- import { readFile as readFile3, writeFile } from "fs/promises";
269
- import { join as join4 } from "path";
421
+ // src/core/clean.ts
422
+ import { readFile as readFile5, readdir as readdir2 } from "fs/promises";
423
+ import { join as join5 } from "path";
270
424
 
271
- // src/utils/utc-date.ts
272
- function pad2(value) {
273
- return String(value).padStart(2, "0");
425
+ // src/core/git.ts
426
+ import { execFile } from "child_process";
427
+ function execGit(args, cwd) {
428
+ return new Promise((resolve, reject) => {
429
+ execFile("git", [...args], { cwd }, (error, stdout, stderr) => {
430
+ if (error !== null) {
431
+ const command = args[0] ?? "unknown";
432
+ reject(new Error(`git ${command} failed: ${stderr.trim() || error.message}`));
433
+ return;
434
+ }
435
+ resolve(stdout.trim());
436
+ });
437
+ });
274
438
  }
275
- function formatUtcDate(date) {
276
- const year = String(date.getUTCFullYear());
277
- const month = pad2(date.getUTCMonth() + 1);
278
- const day = pad2(date.getUTCDate());
279
- return `${year}-${month}-${day}`;
439
+ async function getCurrentBranch(cwd) {
440
+ const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
441
+ if (branch === "HEAD") {
442
+ throw new Error(
443
+ "Detached HEAD state. Cannot determine base branch. Use --no-git to skip worktree creation."
444
+ );
445
+ }
446
+ return branch;
280
447
  }
281
- function formatUtcTimestamp(date) {
282
- const year = String(date.getUTCFullYear());
283
- const month = pad2(date.getUTCMonth() + 1);
284
- const day = pad2(date.getUTCDate());
285
- const hour = pad2(date.getUTCHours());
286
- const minute = pad2(date.getUTCMinutes());
287
- const second = pad2(date.getUTCSeconds());
288
- return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
448
+ async function addWorktree(worktreePath, branch, baseBranch, cwd) {
449
+ await execGit(["worktree", "add", worktreePath, "-b", branch, baseBranch], cwd);
450
+ }
451
+ async function removeWorktree(worktreePath, cwd) {
452
+ await execGit(["worktree", "remove", worktreePath], cwd);
453
+ }
454
+ async function isBranchMerged(branch, baseBranch, cwd) {
455
+ const output = await execGit(["branch", "--merged", baseBranch], cwd);
456
+ const branches = output.split("\n").map((line) => line.replace(/^\*?\s*/, "").trim());
457
+ return branches.includes(branch);
458
+ }
459
+ async function deleteBranch(branch, force, cwd) {
460
+ const flag = force ? "-D" : "-d";
461
+ await execGit(["branch", flag, branch], cwd);
462
+ }
463
+
464
+ // src/core/clean.ts
465
+ var CLEANABLE_STATUSES = /* @__PURE__ */ new Set(["DONE", "ABANDONED"]);
466
+ async function readTasksFromDirectory(_projectRoot, dirPath, relativeDirPath) {
467
+ if (await getFsEntryKind(dirPath) !== "directory") {
468
+ return [];
469
+ }
470
+ const entries = await readdir2(dirPath, { withFileTypes: true });
471
+ const tasks = [];
472
+ for (const entry of entries) {
473
+ if (!entry.isDirectory()) {
474
+ continue;
475
+ }
476
+ const metaPath = join5(dirPath, entry.name, "meta.yml");
477
+ if (await getFsEntryKind(metaPath) !== "file") {
478
+ continue;
479
+ }
480
+ const content = await readFile5(metaPath, "utf8");
481
+ const meta = parseMetaText(content);
482
+ if (meta.id !== void 0 && meta.status !== void 0) {
483
+ const task = {
484
+ id: meta.id,
485
+ path: join5(relativeDirPath, entry.name),
486
+ status: meta.status
487
+ };
488
+ if (meta.baseBranch !== void 0) task.baseBranch = meta.baseBranch;
489
+ if (meta.branch !== void 0) task.branch = meta.branch;
490
+ if (meta.slug !== void 0) task.slug = meta.slug;
491
+ if (meta.worktreePath !== void 0) task.worktreePath = meta.worktreePath;
492
+ tasks.push(task);
493
+ }
494
+ }
495
+ return tasks;
496
+ }
497
+ async function readArchiveTasks(projectRoot) {
498
+ const archivePath = getProjectSduckArchivePath(projectRoot);
499
+ if (await getFsEntryKind(archivePath) !== "directory") {
500
+ return [];
501
+ }
502
+ const months = await readdir2(archivePath, { withFileTypes: true });
503
+ const tasks = [];
504
+ for (const month of months) {
505
+ if (!month.isDirectory()) {
506
+ continue;
507
+ }
508
+ const monthPath = join5(archivePath, month.name);
509
+ const relativePath = join5(".sduck/sduck-archive", month.name);
510
+ const monthTasks = await readTasksFromDirectory(projectRoot, monthPath, relativePath);
511
+ tasks.push(...monthTasks);
512
+ }
513
+ return tasks;
514
+ }
515
+ async function resolveCleanCandidates(projectRoot, target) {
516
+ if (target !== void 0 && target.trim() !== "") {
517
+ const trimmed = target.trim();
518
+ const workspacePath2 = getProjectSduckWorkspacePath(projectRoot);
519
+ const workspaceTasks = await readTasksFromDirectory(
520
+ projectRoot,
521
+ workspacePath2,
522
+ ".sduck/sduck-workspace"
523
+ );
524
+ const archiveTasks = await readArchiveTasks(projectRoot);
525
+ const allTasks = [...workspaceTasks, ...archiveTasks];
526
+ const idMatch = allTasks.find((task) => task.id === trimmed);
527
+ if (idMatch !== void 0) {
528
+ if (!CLEANABLE_STATUSES.has(idMatch.status)) {
529
+ throw new Error(
530
+ `Cannot clean work ${idMatch.id}: status is ${idMatch.status}. Only DONE or ABANDONED works can be cleaned.`
531
+ );
532
+ }
533
+ return [toCleanCandidate(idMatch)];
534
+ }
535
+ const slugMatches = allTasks.filter((task) => task.slug === trimmed);
536
+ if (slugMatches.length === 1) {
537
+ const match = slugMatches[0];
538
+ if (match === void 0) {
539
+ throw new Error(`No work matches '${trimmed}'.`);
540
+ }
541
+ if (!CLEANABLE_STATUSES.has(match.status)) {
542
+ throw new Error(
543
+ `Cannot clean work ${match.id}: status is ${match.status}. Only DONE or ABANDONED works can be cleaned.`
544
+ );
545
+ }
546
+ return [toCleanCandidate(match)];
547
+ }
548
+ if (slugMatches.length > 1) {
549
+ const candidates = slugMatches.map((task) => task.id).join(", ");
550
+ throw new Error(
551
+ `Multiple works match slug '${trimmed}': ${candidates}. Use \`sduck clean <id>\` to specify.`
552
+ );
553
+ }
554
+ throw new Error(`No work matches '${trimmed}'.`);
555
+ }
556
+ const workspacePath = getProjectSduckWorkspacePath(projectRoot);
557
+ const tasks = await readTasksFromDirectory(projectRoot, workspacePath, ".sduck/sduck-workspace");
558
+ return tasks.filter((task) => CLEANABLE_STATUSES.has(task.status)).map(toCleanCandidate);
559
+ }
560
+ function toCleanCandidate(task) {
561
+ return {
562
+ baseBranch: task.baseBranch ?? null,
563
+ branch: task.branch ?? null,
564
+ id: task.id,
565
+ path: task.path,
566
+ worktreePath: task.worktreePath ?? null
567
+ };
568
+ }
569
+ async function runCleanWorkflow(projectRoot, target, force = false) {
570
+ const candidates = await resolveCleanCandidates(projectRoot, target);
571
+ const cleaned = [];
572
+ for (const candidate of candidates) {
573
+ if (candidate.branch === null || candidate.baseBranch === null) {
574
+ cleaned.push({
575
+ branchDeleted: false,
576
+ note: "no git resources to clean (--no-git work)",
577
+ workId: candidate.id,
578
+ worktreeRemoved: false
579
+ });
580
+ continue;
581
+ }
582
+ const merged = await isBranchMerged(candidate.branch, candidate.baseBranch, projectRoot);
583
+ let worktreeRemoved = false;
584
+ if (candidate.worktreePath !== null) {
585
+ const absoluteWorktreePath = join5(projectRoot, candidate.worktreePath);
586
+ if (await getFsEntryKind(absoluteWorktreePath) === "missing") {
587
+ console.warn(
588
+ `Warning: worktree path ${candidate.worktreePath} does not exist for work ${candidate.id}.`
589
+ );
590
+ } else {
591
+ await removeWorktree(absoluteWorktreePath, projectRoot);
592
+ worktreeRemoved = true;
593
+ }
594
+ }
595
+ let branchDeleted = false;
596
+ if (merged) {
597
+ await deleteBranch(candidate.branch, false, projectRoot);
598
+ branchDeleted = true;
599
+ } else if (force) {
600
+ await deleteBranch(candidate.branch, true, projectRoot);
601
+ branchDeleted = true;
602
+ } else {
603
+ console.warn(
604
+ `Warning: branch ${candidate.branch} is not merged into ${candidate.baseBranch}. Use --force to delete.`
605
+ );
606
+ }
607
+ cleaned.push({
608
+ branchDeleted,
609
+ note: merged ? "cleaned (merged)" : force ? "cleaned (forced)" : "worktree removed, branch kept",
610
+ workId: candidate.id,
611
+ worktreeRemoved
612
+ });
613
+ }
614
+ return { cleaned };
615
+ }
616
+
617
+ // src/commands/clean.ts
618
+ async function runCleanCommand(options, projectRoot) {
619
+ try {
620
+ const result = await runCleanWorkflow(projectRoot, options.target, options.force ?? false);
621
+ if (result.cleaned.length === 0) {
622
+ return {
623
+ exitCode: 0,
624
+ stderr: "",
625
+ stdout: "\uC815\uB9AC\uD560 \uC791\uC5C5\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."
626
+ };
627
+ }
628
+ const lines = result.cleaned.map(
629
+ (row) => `${row.workId}: ${row.note}` + (row.worktreeRemoved ? " (worktree \uC81C\uAC70\uB428)" : "") + (row.branchDeleted ? " (\uBE0C\uB79C\uCE58 \uC0AD\uC81C\uB428)" : "")
630
+ );
631
+ return {
632
+ exitCode: 0,
633
+ stderr: "",
634
+ stdout: lines.join("\n")
635
+ };
636
+ } catch (error) {
637
+ return {
638
+ exitCode: 1,
639
+ stderr: error instanceof Error ? error.message : "Unknown clean failure.",
640
+ stdout: ""
641
+ };
642
+ }
289
643
  }
290
644
 
291
645
  // src/core/done.ts
646
+ import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
647
+ import { join as join6 } from "path";
292
648
  var TASK_EVAL_ASSET_PATH = getProjectRelativeSduckAssetPath("eval", "task.yml");
293
649
  function filterDoneCandidates(tasks) {
294
- return tasks.filter((task) => task.status === "IN_PROGRESS");
650
+ return tasks.filter((task) => task.status === "REVIEW_READY");
295
651
  }
296
652
  function resolveDoneTargetMatches(tasks, target) {
297
653
  if (target === void 0 || target.trim() === "") {
@@ -322,8 +678,8 @@ function parseCompletedStepNumbers(value) {
322
678
  });
323
679
  }
324
680
  function validateDoneMetaContent(metaContent) {
325
- const totalMatch = /^ {2}total:\s+(.+)$/m.exec(metaContent);
326
- const completedMatch = /^ {2}completed:\s+\[(.*)\]$/m.exec(metaContent);
681
+ const totalMatch = /^ {2}total:[ \t]+(.+)$/m.exec(metaContent);
682
+ const completedMatch = /^ {2}completed:[ \t]+\[(.*)\]$/m.exec(metaContent);
327
683
  if (totalMatch?.[1] === void 0 || completedMatch?.[1] === void 0) {
328
684
  throw new Error("Task meta is missing a valid steps block.");
329
685
  }
@@ -365,16 +721,29 @@ function validateDoneTarget(task) {
365
721
  if (task.status === "DONE") {
366
722
  throw new Error(`Task ${task.id} is already DONE.`);
367
723
  }
368
- if (task.status !== "IN_PROGRESS") {
369
- throw new Error(`Task ${task.id} is not in progress (${task.status}).`);
724
+ if (task.status !== "REVIEW_READY") {
725
+ throw new Error(
726
+ `Task ${task.id} is not in REVIEW_READY state (${task.status}). Run \`sduck review ready\` first.`
727
+ );
728
+ }
729
+ }
730
+ async function checkReviewWarning(projectRoot, task) {
731
+ const reviewPath = join6(projectRoot, task.path, "review.md");
732
+ if (await getFsEntryKind(reviewPath) !== "file") {
733
+ return `review.md is missing for task ${task.id}. Consider running \`sduck review ready\` to create it.`;
734
+ }
735
+ const content = await readFile6(reviewPath, "utf8");
736
+ if (content.trim().length === 0) {
737
+ return `review.md is empty for task ${task.id}.`;
370
738
  }
739
+ return void 0;
371
740
  }
372
741
  async function loadTaskEvalCriteria(projectRoot) {
373
- const taskEvalPath = join4(projectRoot, TASK_EVAL_ASSET_PATH);
742
+ const taskEvalPath = join6(projectRoot, TASK_EVAL_ASSET_PATH);
374
743
  if (await getFsEntryKind(taskEvalPath) !== "file") {
375
744
  throw new Error(`Missing task evaluation asset at ${TASK_EVAL_ASSET_PATH}.`);
376
745
  }
377
- const taskEvalContent = await readFile3(taskEvalPath, "utf8");
746
+ const taskEvalContent = await readFile6(taskEvalPath, "utf8");
378
747
  const labels = extractTaskEvalCriteriaLabels(taskEvalContent);
379
748
  if (labels.length === 0) {
380
749
  throw new Error(`Task evaluation asset has no criteria labels: ${TASK_EVAL_ASSET_PATH}.`);
@@ -383,25 +752,31 @@ async function loadTaskEvalCriteria(projectRoot) {
383
752
  }
384
753
  async function completeTask(projectRoot, task, completedAt, taskEvalCriteria) {
385
754
  validateDoneTarget(task);
386
- const metaPath = join4(projectRoot, task.path, "meta.yml");
387
- const specPath = join4(projectRoot, task.path, "spec.md");
755
+ const metaPath = join6(projectRoot, task.path, "meta.yml");
756
+ const specPath = join6(projectRoot, task.path, "spec.md");
388
757
  if (await getFsEntryKind(metaPath) !== "file") {
389
758
  throw new Error(`Missing meta.yml for task ${task.id}.`);
390
759
  }
391
760
  if (await getFsEntryKind(specPath) !== "file") {
392
761
  throw new Error(`Missing spec.md for task ${task.id}.`);
393
762
  }
394
- const metaContent = await readFile3(metaPath, "utf8");
763
+ const reviewWarning = await checkReviewWarning(projectRoot, task);
764
+ const metaContent = await readFile6(metaPath, "utf8");
395
765
  validateDoneMetaContent(metaContent);
396
- const specContent = await readFile3(specPath, "utf8");
766
+ const specContent = await readFile6(specPath, "utf8");
397
767
  const uncheckedItems = extractUncheckedChecklistItems(specContent);
398
768
  if (uncheckedItems.length > 0) {
399
769
  throw new Error(`Spec checklist is incomplete: ${uncheckedItems.join("; ")}`);
400
770
  }
401
- await writeFile(metaPath, updateDoneBlock(metaContent, completedAt), "utf8");
771
+ await writeFile3(metaPath, updateDoneBlock(metaContent, completedAt), "utf8");
772
+ const currentWorkId = await readCurrentWorkId(projectRoot);
773
+ if (currentWorkId === task.id) {
774
+ await writeCurrentWorkId(projectRoot, null);
775
+ }
402
776
  return {
403
777
  completedAt,
404
778
  note: `task eval checked (${String(taskEvalCriteria.length)} criteria)`,
779
+ reviewWarning,
405
780
  taskEvalCriteria: [...taskEvalCriteria],
406
781
  taskId: task.id
407
782
  };
@@ -431,20 +806,20 @@ async function runDoneWorkflow(projectRoot, tasks, completedAt) {
431
806
  };
432
807
  }
433
808
  async function loadDoneTargets(projectRoot, input) {
434
- const tasks = await listWorkspaceTasks(projectRoot);
435
- const matches = resolveDoneTargetMatches(tasks, input.target);
436
809
  if (input.target === void 0 || input.target.trim() === "") {
437
- if (matches.length === 0) {
438
- throw new Error("No IN_PROGRESS task found. Run `sduck done <slug>` after choosing a task.");
810
+ const currentWorkId = await readCurrentWorkId(projectRoot);
811
+ if (currentWorkId === null) {
812
+ throwNoCurrentWorkError("done");
439
813
  }
440
- if (matches.length > 1) {
441
- const labels = matches.map((task) => task.slug ?? task.id).join(", ");
442
- throw new Error(
443
- `Multiple IN_PROGRESS tasks found: ${labels}. Rerun with \`sduck done <slug>\` or \`sduck done <id>\`.`
444
- );
814
+ const tasks2 = await listWorkspaceTasks(projectRoot);
815
+ const match = tasks2.find((task) => task.id === currentWorkId);
816
+ if (match === void 0) {
817
+ throw new Error(`Current work ${currentWorkId} not found in workspace.`);
445
818
  }
446
- return matches;
819
+ return [match];
447
820
  }
821
+ const tasks = await listWorkspaceTasks(projectRoot);
822
+ const matches = resolveDoneTargetMatches(tasks, input.target);
448
823
  if (matches.length === 0) {
449
824
  throw new Error(`No task matches target '${input.target.trim()}'.`);
450
825
  }
@@ -507,6 +882,11 @@ function formatSuccess(result) {
507
882
  lines.push(`task eval \uAE30\uC900: ${criteriaLabels.join(", ")}`);
508
883
  }
509
884
  }
885
+ for (const row of result.succeeded) {
886
+ if (row.reviewWarning !== void 0) {
887
+ lines.push(`\uACBD\uACE0: ${row.reviewWarning}`);
888
+ }
889
+ }
510
890
  lines.push(...formatFailureDetails(result.failed));
511
891
  return lines.join("\n");
512
892
  }
@@ -539,11 +919,11 @@ async function runDoneCommand(input, projectRoot) {
539
919
  import { confirm } from "@inquirer/prompts";
540
920
 
541
921
  // src/core/fast-track.ts
542
- import { writeFile as writeFile5 } from "fs/promises";
543
- import { join as join9 } from "path";
922
+ import { writeFile as writeFile8 } from "fs/promises";
923
+ import { join as join12 } from "path";
544
924
 
545
925
  // src/core/assets.ts
546
- import { dirname, join as join5 } from "path";
926
+ import { dirname, join as join7 } from "path";
547
927
  import { fileURLToPath } from "url";
548
928
  var SUPPORTED_TASK_TYPES = [
549
929
  "build",
@@ -553,16 +933,16 @@ var SUPPORTED_TASK_TYPES = [
553
933
  "chore"
554
934
  ];
555
935
  var EVAL_ASSET_RELATIVE_PATHS = {
556
- task: join5("eval", "task.yml"),
557
- plan: join5("eval", "plan.yml"),
558
- spec: join5("eval", "spec.yml")
936
+ task: join7("eval", "task.yml"),
937
+ plan: join7("eval", "plan.yml"),
938
+ spec: join7("eval", "spec.yml")
559
939
  };
560
940
  var SPEC_TEMPLATE_RELATIVE_PATHS = {
561
- build: join5("types", "build.md"),
562
- feature: join5("types", "feature.md"),
563
- fix: join5("types", "fix.md"),
564
- refactor: join5("types", "refactor.md"),
565
- chore: join5("types", "chore.md")
941
+ build: join7("types", "build.md"),
942
+ feature: join7("types", "feature.md"),
943
+ fix: join7("types", "fix.md"),
944
+ refactor: join7("types", "refactor.md"),
945
+ chore: join7("types", "chore.md")
566
946
  };
567
947
  var INIT_ASSET_RELATIVE_PATHS = [
568
948
  EVAL_ASSET_RELATIVE_PATHS.spec,
@@ -573,8 +953,8 @@ var INIT_ASSET_RELATIVE_PATHS = [
573
953
  async function getBundledAssetsRoot() {
574
954
  const currentDirectoryPath = dirname(fileURLToPath(import.meta.url));
575
955
  const candidatePaths = [
576
- join5(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets"),
577
- join5(currentDirectoryPath, "..", ".sduck", "sduck-assets")
956
+ join7(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets"),
957
+ join7(currentDirectoryPath, "..", ".sduck", "sduck-assets")
578
958
  ];
579
959
  for (const candidatePath of candidatePaths) {
580
960
  if (await getFsEntryKind(candidatePath) === "directory") {
@@ -591,8 +971,8 @@ function resolveSpecTemplateRelativePath(type) {
591
971
  }
592
972
 
593
973
  // src/core/plan-approve.ts
594
- import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
595
- import { join as join6 } from "path";
974
+ import { readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
975
+ import { join as join8 } from "path";
596
976
  function filterPlanApprovalCandidates(tasks) {
597
977
  return tasks.filter((task) => task.status === "SPEC_APPROVED");
598
978
  }
@@ -634,8 +1014,8 @@ async function approvePlans(projectRoot, tasks, approvedAt) {
634
1014
  });
635
1015
  continue;
636
1016
  }
637
- const metaPath = join6(projectRoot, task.path, "meta.yml");
638
- const planPath = join6(projectRoot, task.path, "plan.md");
1017
+ const metaPath = join8(projectRoot, task.path, "meta.yml");
1018
+ const planPath = join8(projectRoot, task.path, "plan.md");
639
1019
  if (await getFsEntryKind(metaPath) !== "file") {
640
1020
  failed.push({ note: "missing meta.yml", taskId: task.id });
641
1021
  continue;
@@ -644,18 +1024,18 @@ async function approvePlans(projectRoot, tasks, approvedAt) {
644
1024
  failed.push({ note: "missing plan.md", taskId: task.id });
645
1025
  continue;
646
1026
  }
647
- const planContent = await readFile4(planPath, "utf8");
1027
+ const planContent = await readFile7(planPath, "utf8");
648
1028
  const totalSteps = countPlanSteps(planContent);
649
1029
  if (totalSteps === 0) {
650
1030
  failed.push({ note: "missing valid Step headers", taskId: task.id });
651
1031
  continue;
652
1032
  }
653
1033
  const updatedMeta = updatePlanApprovalBlock(
654
- await readFile4(metaPath, "utf8"),
1034
+ await readFile7(metaPath, "utf8"),
655
1035
  approvedAt,
656
1036
  totalSteps
657
1037
  );
658
- await writeFile2(metaPath, updatedMeta, "utf8");
1038
+ await writeFile4(metaPath, updatedMeta, "utf8");
659
1039
  succeeded.push({ note: "moved to IN_PROGRESS", steps: totalSteps, taskId: task.id });
660
1040
  }
661
1041
  return {
@@ -667,15 +1047,22 @@ async function approvePlans(projectRoot, tasks, approvedAt) {
667
1047
  }
668
1048
  async function loadPlanApprovalCandidates(projectRoot, input) {
669
1049
  const tasks = await listWorkspaceTasks(projectRoot);
670
- return resolvePlanApprovalCandidates(tasks, input.target);
1050
+ if (input.target !== void 0) {
1051
+ return resolvePlanApprovalCandidates(tasks, input.target);
1052
+ }
1053
+ const currentWorkId = await readCurrentWorkId(projectRoot);
1054
+ if (currentWorkId !== null) {
1055
+ return resolvePlanApprovalCandidates(tasks, currentWorkId);
1056
+ }
1057
+ return resolvePlanApprovalCandidates(tasks, void 0);
671
1058
  }
672
1059
  function createPlanApprovedAt(date = /* @__PURE__ */ new Date()) {
673
1060
  return formatUtcTimestamp(date);
674
1061
  }
675
1062
 
676
1063
  // src/core/spec-approve.ts
677
- import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
678
- import { join as join7 } from "path";
1064
+ import { readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
1065
+ import { join as join9 } from "path";
679
1066
  function filterApprovalCandidates(tasks) {
680
1067
  return tasks.filter((task) => task.status === "PENDING_SPEC_APPROVAL");
681
1068
  }
@@ -710,12 +1097,12 @@ function updateSpecApprovalBlock(metaContent, approvedAt) {
710
1097
  async function approveSpecs(projectRoot, tasks, approvedAt) {
711
1098
  validateSpecApprovalTargets(tasks);
712
1099
  for (const task of tasks) {
713
- const metaPath = join7(projectRoot, task.path, "meta.yml");
1100
+ const metaPath = join9(projectRoot, task.path, "meta.yml");
714
1101
  if (await getFsEntryKind(metaPath) !== "file") {
715
1102
  throw new Error(`Missing meta.yml for task ${task.id}.`);
716
1103
  }
717
- const updatedContent = updateSpecApprovalBlock(await readFile5(metaPath, "utf8"), approvedAt);
718
- await writeFile3(metaPath, updatedContent, "utf8");
1104
+ const updatedContent = updateSpecApprovalBlock(await readFile8(metaPath, "utf8"), approvedAt);
1105
+ await writeFile5(metaPath, updatedContent, "utf8");
719
1106
  }
720
1107
  return {
721
1108
  approvedAt,
@@ -725,15 +1112,63 @@ async function approveSpecs(projectRoot, tasks, approvedAt) {
725
1112
  }
726
1113
  async function loadSpecApprovalCandidates(projectRoot, input) {
727
1114
  const tasks = await listWorkspaceTasks(projectRoot);
728
- return resolveTargetCandidates(tasks, input.target);
1115
+ if (input.target !== void 0) {
1116
+ return resolveTargetCandidates(tasks, input.target);
1117
+ }
1118
+ const currentWorkId = await readCurrentWorkId(projectRoot);
1119
+ if (currentWorkId !== null) {
1120
+ return resolveTargetCandidates(tasks, currentWorkId);
1121
+ }
1122
+ return resolveTargetCandidates(tasks, void 0);
729
1123
  }
730
1124
  function createSpecApprovedAt(date = /* @__PURE__ */ new Date()) {
731
1125
  return formatUtcTimestamp(date);
732
1126
  }
733
1127
 
734
1128
  // src/core/start.ts
735
- import { mkdir as mkdir2, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
736
- import { join as join8 } from "path";
1129
+ import { mkdir as mkdir2, readFile as readFile10, readdir as readdir3, rm, writeFile as writeFile7 } from "fs/promises";
1130
+ import { join as join11 } from "path";
1131
+
1132
+ // src/core/gitignore.ts
1133
+ import { readFile as readFile9, writeFile as writeFile6 } from "fs/promises";
1134
+ import { join as join10 } from "path";
1135
+ async function ensureGitignoreEntries(projectRoot, entries) {
1136
+ const gitignorePath = join10(projectRoot, ".gitignore");
1137
+ const added = [];
1138
+ const skipped = [];
1139
+ try {
1140
+ let content = "";
1141
+ if (await getFsEntryKind(gitignorePath) === "file") {
1142
+ content = await readFile9(gitignorePath, "utf8");
1143
+ }
1144
+ const existingLines = new Set(content.split("\n").map((line) => line.trim()));
1145
+ const toAdd = [];
1146
+ for (const entry of entries) {
1147
+ if (existingLines.has(entry)) {
1148
+ skipped.push(entry);
1149
+ } else {
1150
+ toAdd.push(entry);
1151
+ added.push(entry);
1152
+ }
1153
+ }
1154
+ if (toAdd.length > 0) {
1155
+ const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
1156
+ const newContent = content + suffix + toAdd.join("\n") + "\n";
1157
+ await writeFile6(gitignorePath, newContent, "utf8");
1158
+ }
1159
+ return { added, skipped };
1160
+ } catch {
1161
+ const manualEntries = entries.join("\n");
1162
+ return {
1163
+ added: [],
1164
+ skipped: [],
1165
+ warning: `Failed to update .gitignore. Please add the following entries manually:
1166
+ ${manualEntries}`
1167
+ };
1168
+ }
1169
+ }
1170
+
1171
+ // src/core/start.ts
737
1172
  function normalizeSlug(input) {
738
1173
  return input.trim().toLowerCase().replace(/[_\s]+/g, "-").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
739
1174
  }
@@ -753,12 +1188,49 @@ function createWorkspaceId(date, type, slug) {
753
1188
  const minute = String(date.getUTCMinutes()).padStart(2, "0");
754
1189
  return `${year}${month}${day}-${hour}${minute}-${type}-${slug}`;
755
1190
  }
1191
+ async function existsInArchive(projectRoot, id) {
1192
+ const archivePath = getProjectSduckArchivePath(projectRoot);
1193
+ if (await getFsEntryKind(archivePath) !== "directory") {
1194
+ return false;
1195
+ }
1196
+ const months = await readdir3(archivePath, { withFileTypes: true });
1197
+ for (const month of months) {
1198
+ if (!month.isDirectory()) {
1199
+ continue;
1200
+ }
1201
+ const candidatePath = join11(archivePath, month.name, id);
1202
+ if (await getFsEntryKind(candidatePath) !== "missing") {
1203
+ return true;
1204
+ }
1205
+ }
1206
+ return false;
1207
+ }
1208
+ async function resolveUniqueWorkspaceId(projectRoot, baseId) {
1209
+ const workspacePath = getProjectRelativeSduckWorkspacePath(baseId);
1210
+ const absolutePath = join11(projectRoot, workspacePath);
1211
+ if (await getFsEntryKind(absolutePath) === "missing" && !await existsInArchive(projectRoot, baseId)) {
1212
+ return baseId;
1213
+ }
1214
+ for (let suffix = 2; suffix <= 100; suffix += 1) {
1215
+ const candidateId = `${baseId}-${String(suffix)}`;
1216
+ const candidatePath = join11(projectRoot, getProjectRelativeSduckWorkspacePath(candidateId));
1217
+ if (await getFsEntryKind(candidatePath) === "missing" && !await existsInArchive(projectRoot, candidateId)) {
1218
+ return candidateId;
1219
+ }
1220
+ }
1221
+ throw new Error(`Cannot resolve unique workspace id for ${baseId}: too many collisions.`);
1222
+ }
756
1223
  function renderInitialMeta(input) {
757
1224
  return [
758
1225
  `id: ${input.id}`,
759
1226
  `type: ${input.type}`,
760
1227
  `slug: ${input.slug}`,
761
1228
  `created_at: ${input.createdAt}`,
1229
+ `updated_at: ${input.updatedAt}`,
1230
+ "",
1231
+ `branch: ${input.branch ?? "null"}`,
1232
+ `base_branch: ${input.baseBranch ?? "null"}`,
1233
+ `worktree_path: ${input.worktreePath ?? "null"}`,
762
1234
  "",
763
1235
  "status: PENDING_SPEC_APPROVAL",
764
1236
  "",
@@ -780,49 +1252,69 @@ function renderInitialMeta(input) {
780
1252
  }
781
1253
  async function resolveSpecTemplatePath(type) {
782
1254
  const assetsRoot = await getBundledAssetsRoot();
783
- return join8(assetsRoot, resolveSpecTemplateRelativePath(type));
1255
+ return join11(assetsRoot, resolveSpecTemplateRelativePath(type));
784
1256
  }
785
1257
  function applyTemplateDefaults(template, type, slug, currentDate) {
786
1258
  const displayName = slug.replace(/-/g, " ");
787
1259
  return template.replace(/\{기능명\}/g, displayName).replace(/\{버그 요약 한 줄\}/g, displayName).replace(/YYYY-MM-DD/g, formatUtcDate(currentDate)).replace(/> \*\*작성자:\*\*\s*$/m, "> **\uC791\uC131\uC790:** taehee").replace(/> \*\*연관 티켓:\*\*\s*$/m, "> **\uC5F0\uAD00 \uD2F0\uCF13:** -").replace(/^# \[(feature|fix|refactor|chore|build)\] .*/m, `# [${type}] ${displayName}`);
788
1260
  }
789
- async function startTask(rawType, rawSlug, projectRoot, currentDate = /* @__PURE__ */ new Date()) {
1261
+ async function startTask(rawType, rawSlug, projectRoot, currentDate = /* @__PURE__ */ new Date(), options) {
790
1262
  if (!isSupportedTaskType(rawType)) {
791
1263
  throw new Error(`Unsupported type: ${rawType}`);
792
1264
  }
793
1265
  const slug = normalizeSlug(rawSlug);
794
1266
  validateSlug(slug);
795
- const activeTask = await findActiveTask(projectRoot);
796
- if (activeTask !== null) {
797
- throw new Error(
798
- `Active task exists: ${activeTask.id} (${activeTask.status}) at ${activeTask.path}. Finish or approve it before starting a new task.`
799
- );
800
- }
801
- const workspaceId = createWorkspaceId(currentDate, rawType, slug);
1267
+ const baseId = createWorkspaceId(currentDate, rawType, slug);
1268
+ const workspaceId = await resolveUniqueWorkspaceId(projectRoot, baseId);
802
1269
  const workspacePath = getProjectRelativeSduckWorkspacePath(workspaceId);
803
- const absoluteWorkspacePath = join8(projectRoot, workspacePath);
804
- if (await getFsEntryKind(absoluteWorkspacePath) !== "missing") {
805
- throw new Error(`Workspace already exists: ${workspacePath}`);
806
- }
1270
+ const absoluteWorkspacePath = join11(projectRoot, workspacePath);
807
1271
  const workspaceRoot = getProjectSduckWorkspacePath(projectRoot);
808
1272
  await mkdir2(workspaceRoot, { recursive: true });
809
1273
  await mkdir2(absoluteWorkspacePath, { recursive: false });
1274
+ let branch = null;
1275
+ let baseBranch = null;
1276
+ let worktreePath = null;
1277
+ if (options?.noGit !== true) {
1278
+ try {
1279
+ baseBranch = await getCurrentBranch(projectRoot);
1280
+ branch = `work/${rawType}/${slug}`;
1281
+ const absoluteWorktreePath = getProjectWorktreePath(projectRoot, workspaceId);
1282
+ worktreePath = `${SDUCK_WORKTREES_DIR}/${workspaceId}`;
1283
+ await addWorktree(absoluteWorktreePath, branch, baseBranch, projectRoot);
1284
+ } catch (error) {
1285
+ await rm(absoluteWorkspacePath, { recursive: true, force: true });
1286
+ throw error;
1287
+ }
1288
+ }
810
1289
  const templatePath = await resolveSpecTemplatePath(rawType);
811
1290
  if (await getFsEntryKind(templatePath) !== "file") {
812
1291
  throw new Error(`Missing spec template for type '${rawType}' at ${templatePath}`);
813
1292
  }
814
- const specTemplate = await readFile6(templatePath, "utf8");
1293
+ const specTemplate = await readFile10(templatePath, "utf8");
815
1294
  const specContent = applyTemplateDefaults(specTemplate, rawType, slug, currentDate);
1295
+ const timestamp = formatUtcTimestamp(currentDate);
816
1296
  const metaContent = renderInitialMeta({
817
- createdAt: formatUtcTimestamp(currentDate),
1297
+ baseBranch,
1298
+ branch,
1299
+ createdAt: timestamp,
818
1300
  id: workspaceId,
819
1301
  slug,
820
- type: rawType
1302
+ type: rawType,
1303
+ updatedAt: timestamp,
1304
+ worktreePath
821
1305
  });
822
- await writeFile4(join8(absoluteWorkspacePath, "meta.yml"), metaContent, "utf8");
823
- await writeFile4(join8(absoluteWorkspacePath, "spec.md"), specContent, "utf8");
824
- await writeFile4(join8(absoluteWorkspacePath, "plan.md"), "", "utf8");
1306
+ await writeFile7(join11(absoluteWorkspacePath, "meta.yml"), metaContent, "utf8");
1307
+ await writeFile7(join11(absoluteWorkspacePath, "spec.md"), specContent, "utf8");
1308
+ await writeFile7(join11(absoluteWorkspacePath, "plan.md"), "", "utf8");
1309
+ const sduckHome = getProjectSduckHomePath(projectRoot);
1310
+ await mkdir2(sduckHome, { recursive: true });
1311
+ await writeCurrentWorkId(projectRoot, workspaceId, currentDate);
1312
+ const gitignoreResult = await ensureGitignoreEntries(projectRoot, [
1313
+ `${SDUCK_WORKTREES_DIR}/`,
1314
+ ".sduck/sduck-state.yml"
1315
+ ]);
825
1316
  return {
1317
+ gitignoreWarning: gitignoreResult.warning,
826
1318
  workspaceId,
827
1319
  workspacePath,
828
1320
  status: "PENDING_SPEC_APPROVAL"
@@ -883,22 +1375,28 @@ function renderMinimalPlan(slug) {
883
1375
  function isInteractiveApprovalAvailable() {
884
1376
  return process.stdin.isTTY && process.stdout.isTTY;
885
1377
  }
886
- async function createFastTrackTask(input, projectRoot) {
1378
+ async function createFastTrackTask(input, projectRoot, startOptions) {
887
1379
  if (!isSupportedTaskType(input.type)) {
888
1380
  throw new Error(`Unsupported type: ${input.type}`);
889
1381
  }
890
- const startedTask = await startTask(input.type, input.slug, projectRoot);
891
- const taskPath = join9(projectRoot, startedTask.workspacePath);
892
- const specPath = join9(taskPath, "spec.md");
893
- const planPath = join9(taskPath, "plan.md");
1382
+ const startedTask = await startTask(
1383
+ input.type,
1384
+ input.slug,
1385
+ projectRoot,
1386
+ /* @__PURE__ */ new Date(),
1387
+ startOptions
1388
+ );
1389
+ const taskPath = join12(projectRoot, startedTask.workspacePath);
1390
+ const specPath = join12(taskPath, "spec.md");
1391
+ const planPath = join12(taskPath, "plan.md");
894
1392
  if (await getFsEntryKind(specPath) !== "file") {
895
1393
  throw new Error(`Missing spec.md for fast-track task ${startedTask.workspaceId}.`);
896
1394
  }
897
1395
  if (await getFsEntryKind(planPath) !== "file") {
898
1396
  throw new Error(`Missing plan.md for fast-track task ${startedTask.workspaceId}.`);
899
1397
  }
900
- await writeFile5(specPath, renderMinimalSpec(input.type, input.slug), "utf8");
901
- await writeFile5(planPath, renderMinimalPlan(input.slug), "utf8");
1398
+ await writeFile8(specPath, renderMinimalSpec(input.type, input.slug), "utf8");
1399
+ await writeFile8(planPath, renderMinimalPlan(input.slug), "utf8");
902
1400
  return {
903
1401
  failed: [],
904
1402
  nextStatus: "PENDING_SPEC_APPROVAL",
@@ -956,9 +1454,9 @@ async function approveFastTrackTask(target, projectRoot) {
956
1454
  function formatFailures(rows) {
957
1455
  return rows.map((row) => `- ${row.taskId}: ${row.note}`);
958
1456
  }
959
- async function runFastTrackCommand(input, projectRoot) {
1457
+ async function runFastTrackCommand(input, projectRoot, startOptions) {
960
1458
  try {
961
- const createdTask = await createFastTrackTask(input, projectRoot);
1459
+ const createdTask = await createFastTrackTask(input, projectRoot, startOptions);
962
1460
  const lines = [
963
1461
  "fast-track task created",
964
1462
  `\uACBD\uB85C: ${createdTask.path}/`,
@@ -1023,16 +1521,86 @@ async function runFastTrackCommand(input, projectRoot) {
1023
1521
  }
1024
1522
  }
1025
1523
 
1524
+ // src/core/implement.ts
1525
+ import { readFile as readFile11 } from "fs/promises";
1526
+ import { join as join13 } from "path";
1527
+ async function resolveImplementContext(projectRoot) {
1528
+ const currentWorkId = await readCurrentWorkId(projectRoot);
1529
+ if (currentWorkId === null) {
1530
+ throwNoCurrentWorkError("implement");
1531
+ }
1532
+ const workspacePath = getProjectRelativeSduckWorkspacePath(currentWorkId);
1533
+ const absolutePath = join13(projectRoot, workspacePath);
1534
+ if (await getFsEntryKind(absolutePath) !== "directory") {
1535
+ throw new Error(
1536
+ `Current work ${currentWorkId} not found in workspace. It may have been archived or removed.`
1537
+ );
1538
+ }
1539
+ const metaPath = join13(absolutePath, "meta.yml");
1540
+ if (await getFsEntryKind(metaPath) !== "file") {
1541
+ throw new Error(`Missing meta.yml for work ${currentWorkId}.`);
1542
+ }
1543
+ const metaContent = await readFile11(metaPath, "utf8");
1544
+ const meta = parseMetaText(metaContent);
1545
+ return {
1546
+ baseBranch: meta.baseBranch ?? null,
1547
+ branch: meta.branch ?? null,
1548
+ id: currentWorkId,
1549
+ planPath: join13(workspacePath, "plan.md"),
1550
+ specPath: join13(workspacePath, "spec.md"),
1551
+ status: meta.status ?? "UNKNOWN",
1552
+ worktreePath: meta.worktreePath ?? null
1553
+ };
1554
+ }
1555
+ function formatImplementOutput(context) {
1556
+ const branchDisplay = context.branch ?? "(none)";
1557
+ const worktreeDisplay = context.worktreePath ?? "(none)";
1558
+ const lines = [
1559
+ `Work ID: ${context.id}`,
1560
+ `Branch: ${branchDisplay}`,
1561
+ `Worktree: ${worktreeDisplay}`,
1562
+ `Spec: ${context.specPath}`,
1563
+ `Plan: ${context.planPath}`,
1564
+ ""
1565
+ ];
1566
+ if (context.worktreePath !== null) {
1567
+ lines.push(
1568
+ "\uB2E4\uC74C \uBA85\uB839\uC73C\uB85C \uCF54\uB529 \uC5D0\uC774\uC804\uD2B8\uB97C \uC2DC\uC791\uD558\uC138\uC694:",
1569
+ ` cd ${context.worktreePath}`,
1570
+ " claude # \uB610\uB294 opencode / codex"
1571
+ );
1572
+ }
1573
+ return lines.join("\n");
1574
+ }
1575
+
1576
+ // src/commands/implement.ts
1577
+ async function runImplementCommand(projectRoot) {
1578
+ try {
1579
+ const context = await resolveImplementContext(projectRoot);
1580
+ return {
1581
+ exitCode: 0,
1582
+ stderr: "",
1583
+ stdout: formatImplementOutput(context)
1584
+ };
1585
+ } catch (error) {
1586
+ return {
1587
+ exitCode: 1,
1588
+ stderr: error instanceof Error ? error.message : "Unknown implement failure.",
1589
+ stdout: ""
1590
+ };
1591
+ }
1592
+ }
1593
+
1026
1594
  // src/commands/init.ts
1027
1595
  import { checkbox } from "@inquirer/prompts";
1028
1596
 
1029
1597
  // src/core/agent-rules.ts
1030
- import { readFile as readFile7 } from "fs/promises";
1031
- import { dirname as dirname2, join as join10 } from "path";
1598
+ import { readFile as readFile12 } from "fs/promises";
1599
+ import { dirname as dirname2, join as join14 } from "path";
1032
1600
  import { fileURLToPath as fileURLToPath2 } from "url";
1033
- var CLAUDE_CODE_HOOK_SETTINGS_PATH = join10(".claude", "settings.json");
1034
- var CLAUDE_CODE_HOOK_SCRIPT_PATH = join10(".claude", "hooks", "sdd-guard.sh");
1035
- var CLAUDE_CODE_HOOK_SOURCE_PATH = join10("hooks", "sdd-guard.sh");
1601
+ var CLAUDE_CODE_HOOK_SETTINGS_PATH = join14(".claude", "settings.json");
1602
+ var CLAUDE_CODE_HOOK_SCRIPT_PATH = join14(".claude", "hooks", "sdd-guard.sh");
1603
+ var CLAUDE_CODE_HOOK_SOURCE_PATH = join14("hooks", "sdd-guard.sh");
1036
1604
  function needsClaudeCodeHook(agents) {
1037
1605
  return agents.includes("claude-code");
1038
1606
  }
@@ -1053,12 +1621,12 @@ var AGENT_RULE_TARGETS = [
1053
1621
  { agentId: "gemini-cli", outputPath: "GEMINI.md", kind: "root-file" },
1054
1622
  {
1055
1623
  agentId: "cursor",
1056
- outputPath: join10(".cursor", "rules", "sduck-core.mdc"),
1624
+ outputPath: join14(".cursor", "rules", "sduck-core.mdc"),
1057
1625
  kind: "managed-file"
1058
1626
  },
1059
1627
  {
1060
1628
  agentId: "antigravity",
1061
- outputPath: join10(".agents", "rules", "sduck-core.md"),
1629
+ outputPath: join14(".agents", "rules", "sduck-core.md"),
1062
1630
  kind: "managed-file"
1063
1631
  }
1064
1632
  ];
@@ -1119,8 +1687,8 @@ function renderManagedBlock(lines) {
1119
1687
  async function getAgentRulesAssetRoot() {
1120
1688
  const currentDirectoryPath = dirname2(fileURLToPath2(import.meta.url));
1121
1689
  const candidatePaths = [
1122
- join10(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets", "agent-rules"),
1123
- join10(currentDirectoryPath, "..", ".sduck", "sduck-assets", "agent-rules")
1690
+ join14(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets", "agent-rules"),
1691
+ join14(currentDirectoryPath, "..", ".sduck", "sduck-assets", "agent-rules")
1124
1692
  ];
1125
1693
  for (const candidatePath of candidatePaths) {
1126
1694
  if (await getFsEntryKind(candidatePath) === "directory") {
@@ -1130,7 +1698,7 @@ async function getAgentRulesAssetRoot() {
1130
1698
  throw new Error("Unable to locate bundled sduck agent rule assets.");
1131
1699
  }
1132
1700
  async function readAssetFile(assetRoot, fileName) {
1133
- return await readFile7(join10(assetRoot, fileName), "utf8");
1701
+ return await readFile12(join14(assetRoot, fileName), "utf8");
1134
1702
  }
1135
1703
  function buildRootFileLines(agentIds, agentSpecificContent) {
1136
1704
  const labels = SUPPORTED_AGENTS.filter((agent) => agentIds.includes(agent.id)).map(
@@ -1192,8 +1760,8 @@ function planAgentRuleActions(mode, targets, existingEntries, existingContents)
1192
1760
  }
1193
1761
 
1194
1762
  // src/core/init.ts
1195
- import { chmod, mkdir as mkdir3, readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
1196
- import { dirname as dirname3, join as join11 } from "path";
1763
+ import { chmod, mkdir as mkdir3, readFile as readFile13, writeFile as writeFile9 } from "fs/promises";
1764
+ import { dirname as dirname3, join as join15 } from "path";
1197
1765
  var ASSET_TEMPLATE_DEFINITIONS = [
1198
1766
  {
1199
1767
  key: "eval-spec",
@@ -1306,7 +1874,7 @@ async function collectExistingEntries(projectRoot) {
1306
1874
  for (const definition of ASSET_TEMPLATE_DEFINITIONS) {
1307
1875
  existingEntries.set(
1308
1876
  definition.relativePath,
1309
- await getFsEntryKind(join11(projectRoot, definition.relativePath))
1877
+ await getFsEntryKind(join15(projectRoot, definition.relativePath))
1310
1878
  );
1311
1879
  }
1312
1880
  return existingEntries;
@@ -1314,9 +1882,9 @@ async function collectExistingEntries(projectRoot) {
1314
1882
  async function collectExistingFileContents(projectRoot, targets) {
1315
1883
  const contents = /* @__PURE__ */ new Map();
1316
1884
  for (const target of targets) {
1317
- const targetPath = join11(projectRoot, target.outputPath);
1885
+ const targetPath = join15(projectRoot, target.outputPath);
1318
1886
  if (await getFsEntryKind(targetPath) === "file") {
1319
- contents.set(target.outputPath, await readFile8(targetPath, "utf8"));
1887
+ contents.set(target.outputPath, await readFile13(targetPath, "utf8"));
1320
1888
  }
1321
1889
  }
1322
1890
  return contents;
@@ -1379,8 +1947,8 @@ async function initProject(options, projectRoot) {
1379
1947
  continue;
1380
1948
  }
1381
1949
  const definition = ASSET_TEMPLATE_MAP[action.key];
1382
- const sourcePath = join11(assetSourceRoot, toBundledAssetRelativePath(definition.relativePath));
1383
- const targetPath = join11(projectRoot, definition.relativePath);
1950
+ const sourcePath = join15(assetSourceRoot, toBundledAssetRelativePath(definition.relativePath));
1951
+ const targetPath = join15(projectRoot, definition.relativePath);
1384
1952
  await ensureReadableFile(sourcePath);
1385
1953
  await mkdir3(dirname3(targetPath), { recursive: true });
1386
1954
  await copyFileIntoPlace(sourcePath, targetPath);
@@ -1390,7 +1958,7 @@ async function initProject(options, projectRoot) {
1390
1958
  for (const target of agentTargets) {
1391
1959
  agentEntryKinds.set(
1392
1960
  target.outputPath,
1393
- await getFsEntryKind(join11(projectRoot, target.outputPath))
1961
+ await getFsEntryKind(join15(projectRoot, target.outputPath))
1394
1962
  );
1395
1963
  }
1396
1964
  const existingContents = await collectExistingFileContents(projectRoot, agentTargets);
@@ -1414,9 +1982,9 @@ async function initProject(options, projectRoot) {
1414
1982
  }
1415
1983
  async function installClaudeCodeHook(projectRoot, summary) {
1416
1984
  const assetRoot = await getBundledAssetsRoot();
1417
- const hookSourcePath = join11(assetRoot, "agent-rules", CLAUDE_CODE_HOOK_SOURCE_PATH);
1418
- const hookTargetPath = join11(projectRoot, CLAUDE_CODE_HOOK_SCRIPT_PATH);
1419
- const settingsPath = join11(projectRoot, CLAUDE_CODE_HOOK_SETTINGS_PATH);
1985
+ const hookSourcePath = join15(assetRoot, "agent-rules", CLAUDE_CODE_HOOK_SOURCE_PATH);
1986
+ const hookTargetPath = join15(projectRoot, CLAUDE_CODE_HOOK_SCRIPT_PATH);
1987
+ const settingsPath = join15(projectRoot, CLAUDE_CODE_HOOK_SETTINGS_PATH);
1420
1988
  await mkdir3(dirname3(hookTargetPath), { recursive: true });
1421
1989
  await copyFileIntoPlace(hookSourcePath, hookTargetPath);
1422
1990
  await chmod(hookTargetPath, 493);
@@ -1438,32 +2006,32 @@ async function installClaudeCodeHook(projectRoot, summary) {
1438
2006
  }
1439
2007
  };
1440
2008
  if (await getFsEntryKind(settingsPath) === "file") {
1441
- const existingContent = await readFile8(settingsPath, "utf8");
2009
+ const existingContent = await readFile13(settingsPath, "utf8");
1442
2010
  try {
1443
2011
  const existing = JSON.parse(existingContent);
1444
2012
  existing["hooks"] = hookConfig.hooks;
1445
- await writeFile6(settingsPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
2013
+ await writeFile9(settingsPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
1446
2014
  summary.overwritten.push(CLAUDE_CODE_HOOK_SETTINGS_PATH);
1447
2015
  summary.rows.push({ path: CLAUDE_CODE_HOOK_SETTINGS_PATH, status: "overwritten" });
1448
2016
  } catch {
1449
- await writeFile6(settingsPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8");
2017
+ await writeFile9(settingsPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8");
1450
2018
  summary.overwritten.push(CLAUDE_CODE_HOOK_SETTINGS_PATH);
1451
2019
  summary.rows.push({ path: CLAUDE_CODE_HOOK_SETTINGS_PATH, status: "overwritten" });
1452
2020
  }
1453
2021
  } else {
1454
2022
  await mkdir3(dirname3(settingsPath), { recursive: true });
1455
- await writeFile6(settingsPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8");
2023
+ await writeFile9(settingsPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8");
1456
2024
  summary.created.push(CLAUDE_CODE_HOOK_SETTINGS_PATH);
1457
2025
  summary.rows.push({ path: CLAUDE_CODE_HOOK_SETTINGS_PATH, status: "created" });
1458
2026
  }
1459
2027
  }
1460
2028
  async function applyAgentRuleActions(projectRoot, actions, existingContents, summary, selectedAgents) {
1461
2029
  for (const action of actions) {
1462
- const targetPath = join11(projectRoot, action.outputPath);
2030
+ const targetPath = join15(projectRoot, action.outputPath);
1463
2031
  const content = await renderAgentRuleContent(action, selectedAgents);
1464
2032
  if (action.mergeMode === "create") {
1465
2033
  await mkdir3(dirname3(targetPath), { recursive: true });
1466
- await writeFile6(targetPath, content, "utf8");
2034
+ await writeFile9(targetPath, content, "utf8");
1467
2035
  summary.created.push(action.outputPath);
1468
2036
  summary.rows.push({ path: action.outputPath, status: "created" });
1469
2037
  continue;
@@ -1477,19 +2045,19 @@ async function applyAgentRuleActions(projectRoot, actions, existingContents, sum
1477
2045
  await mkdir3(dirname3(targetPath), { recursive: true });
1478
2046
  if (action.mergeMode === "prepend") {
1479
2047
  const currentContent = existingContents.get(action.outputPath) ?? "";
1480
- await writeFile6(targetPath, prependManagedBlock(currentContent, content.trimEnd()), "utf8");
2048
+ await writeFile9(targetPath, prependManagedBlock(currentContent, content.trimEnd()), "utf8");
1481
2049
  summary.prepended.push(action.outputPath);
1482
2050
  summary.rows.push({ path: action.outputPath, status: "prepended" });
1483
2051
  continue;
1484
2052
  }
1485
2053
  if (action.mergeMode === "replace-block") {
1486
2054
  const currentContent = existingContents.get(action.outputPath) ?? "";
1487
- await writeFile6(targetPath, replaceManagedBlock(currentContent, content.trimEnd()), "utf8");
2055
+ await writeFile9(targetPath, replaceManagedBlock(currentContent, content.trimEnd()), "utf8");
1488
2056
  summary.overwritten.push(action.outputPath);
1489
2057
  summary.rows.push({ path: action.outputPath, status: "overwritten" });
1490
2058
  continue;
1491
2059
  }
1492
- await writeFile6(targetPath, content, "utf8");
2060
+ await writeFile9(targetPath, content, "utf8");
1493
2061
  summary.overwritten.push(action.outputPath);
1494
2062
  summary.rows.push({ path: action.outputPath, status: "overwritten" });
1495
2063
  }
@@ -1667,10 +2235,10 @@ async function runPlanApproveCommand(input, projectRoot) {
1667
2235
  }
1668
2236
 
1669
2237
  // src/core/reopen.ts
1670
- import { copyFile as copyFile2, readFile as readFile9, unlink, writeFile as writeFile7 } from "fs/promises";
1671
- import { join as join12 } from "path";
2238
+ import { copyFile as copyFile2, readFile as readFile14, unlink, writeFile as writeFile10 } from "fs/promises";
2239
+ import { join as join16 } from "path";
1672
2240
  function getCurrentCycle(metaContent) {
1673
- const match = /^cycle:\s+(\d+)$/m.exec(metaContent);
2241
+ const match = /^cycle:[ \t]+(\d+)$/m.exec(metaContent);
1674
2242
  if (match?.[1] === void 0) {
1675
2243
  return 1;
1676
2244
  }
@@ -1751,17 +2319,17 @@ ${formatCandidateList(slugMatch)}`
1751
2319
  throw new Error(`No DONE task found matching '${trimmedTarget}'.`);
1752
2320
  }
1753
2321
  async function snapshotHistoryFiles(taskDir, currentCycle) {
1754
- const historyDir = join12(taskDir, "history");
2322
+ const historyDir = join16(taskDir, "history");
1755
2323
  const created = [];
1756
2324
  const filesToSnapshot = ["spec.md", "plan.md"];
1757
2325
  const snapshotPaths = [];
1758
2326
  for (const fileName of filesToSnapshot) {
1759
- const sourcePath = join12(taskDir, fileName);
2327
+ const sourcePath = join16(taskDir, fileName);
1760
2328
  if (await getFsEntryKind(sourcePath) !== "file") {
1761
2329
  continue;
1762
2330
  }
1763
2331
  const destName = `${String(currentCycle)}_${fileName}`;
1764
- const destPath = join12(historyDir, destName);
2332
+ const destPath = join16(historyDir, destName);
1765
2333
  if (await getFsEntryKind(destPath) !== "missing") {
1766
2334
  throw new Error(`History snapshot already exists: ${destPath}`);
1767
2335
  }
@@ -1788,15 +2356,15 @@ async function snapshotHistoryFiles(taskDir, currentCycle) {
1788
2356
  return created;
1789
2357
  }
1790
2358
  async function runReopenWorkflow(projectRoot, task) {
1791
- const taskDir = join12(projectRoot, task.path);
1792
- const metaPath = join12(taskDir, "meta.yml");
1793
- const metaContent = await readFile9(metaPath, "utf8");
2359
+ const taskDir = join16(projectRoot, task.path);
2360
+ const metaPath = join16(taskDir, "meta.yml");
2361
+ const metaContent = await readFile14(metaPath, "utf8");
1794
2362
  const currentCycle = getCurrentCycle(metaContent);
1795
2363
  const newCycle = currentCycle + 1;
1796
2364
  const snapshots = await snapshotHistoryFiles(taskDir, currentCycle);
1797
2365
  const updatedMeta = buildReopenedMeta(metaContent, newCycle);
1798
2366
  try {
1799
- await writeFile7(metaPath, updatedMeta, "utf8");
2367
+ await writeFile10(metaPath, updatedMeta, "utf8");
1800
2368
  } catch (error) {
1801
2369
  for (const path of snapshots) {
1802
2370
  try {
@@ -1850,6 +2418,112 @@ async function runReopenCommand(input, projectRoot) {
1850
2418
  }
1851
2419
  }
1852
2420
 
2421
+ // src/core/review-ready.ts
2422
+ import { readFile as readFile15, writeFile as writeFile11 } from "fs/promises";
2423
+ import { join as join17 } from "path";
2424
+ function renderReviewTemplate(workId) {
2425
+ return [
2426
+ `# Review: ${workId}`,
2427
+ "",
2428
+ "## \uBCC0\uACBD \uC694\uC57D",
2429
+ "",
2430
+ "- ",
2431
+ "",
2432
+ "## \uD14C\uC2A4\uD2B8 \uACB0\uACFC",
2433
+ "",
2434
+ "- ",
2435
+ "",
2436
+ "## \uB9AC\uBDF0 \uCCB4\uD06C\uB9AC\uC2A4\uD2B8",
2437
+ "",
2438
+ "- [ ] \uCF54\uB4DC \uD488\uC9C8 \uD655\uC778",
2439
+ "- [ ] \uD14C\uC2A4\uD2B8 \uD1B5\uACFC \uD655\uC778",
2440
+ "- [ ] \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8 \uD655\uC778",
2441
+ ""
2442
+ ].join("\n");
2443
+ }
2444
+ async function resolveReviewTarget(projectRoot, target) {
2445
+ const tasks = await listWorkspaceTasks(projectRoot);
2446
+ if (target !== void 0 && target.trim() !== "") {
2447
+ const trimmed = target.trim();
2448
+ const idMatch = tasks.find((task) => task.id === trimmed);
2449
+ if (idMatch !== void 0) {
2450
+ if (idMatch.status !== "IN_PROGRESS") {
2451
+ throw new Error(
2452
+ `Cannot mark work ${idMatch.id} as review ready: status is ${idMatch.status}, expected IN_PROGRESS.`
2453
+ );
2454
+ }
2455
+ return idMatch;
2456
+ }
2457
+ const slugMatches = tasks.filter((task) => task.slug === trimmed);
2458
+ if (slugMatches.length === 1) {
2459
+ const match2 = slugMatches[0];
2460
+ if (match2 === void 0) {
2461
+ throw new Error(`No work matches '${trimmed}'.`);
2462
+ }
2463
+ if (match2.status !== "IN_PROGRESS") {
2464
+ throw new Error(
2465
+ `Cannot mark work ${match2.id} as review ready: status is ${match2.status}, expected IN_PROGRESS.`
2466
+ );
2467
+ }
2468
+ return match2;
2469
+ }
2470
+ if (slugMatches.length > 1) {
2471
+ const candidates = slugMatches.map((task) => task.id).join(", ");
2472
+ throw new Error(
2473
+ `Multiple works match slug '${trimmed}': ${candidates}. Use \`sduck review ready <id>\` to specify.`
2474
+ );
2475
+ }
2476
+ throw new Error(`No work matches '${trimmed}'.`);
2477
+ }
2478
+ const currentWorkId = await readCurrentWorkId(projectRoot);
2479
+ if (currentWorkId === null) {
2480
+ throwNoCurrentWorkError("review ready");
2481
+ }
2482
+ const match = tasks.find((task) => task.id === currentWorkId);
2483
+ if (match === void 0) {
2484
+ throw new Error(`Current work ${currentWorkId} not found in workspace.`);
2485
+ }
2486
+ if (match.status !== "IN_PROGRESS") {
2487
+ throw new Error(
2488
+ `Cannot mark work ${match.id} as review ready: status is ${match.status}, expected IN_PROGRESS.`
2489
+ );
2490
+ }
2491
+ return match;
2492
+ }
2493
+ async function runReviewReadyWorkflow(projectRoot, target, date = /* @__PURE__ */ new Date()) {
2494
+ const work = await resolveReviewTarget(projectRoot, target);
2495
+ const reviewPath = join17(projectRoot, work.path, "review.md");
2496
+ if (await getFsEntryKind(reviewPath) !== "file") {
2497
+ await writeFile11(reviewPath, renderReviewTemplate(work.id), "utf8");
2498
+ }
2499
+ const metaPath = join17(projectRoot, work.path, "meta.yml");
2500
+ if (await getFsEntryKind(metaPath) !== "file") {
2501
+ throw new Error(`Missing meta.yml for work ${work.id}.`);
2502
+ }
2503
+ const metaContent = await readFile15(metaPath, "utf8");
2504
+ const updatedContent = metaContent.replace(/^status:\s+.+$/m, "status: REVIEW_READY").replace(/^updated_at:\s+.+$/m, `updated_at: ${formatUtcTimestamp(date)}`);
2505
+ await writeFile11(metaPath, updatedContent, "utf8");
2506
+ return { workId: work.id };
2507
+ }
2508
+
2509
+ // src/commands/review.ts
2510
+ async function runReviewReadyCommand(target, projectRoot) {
2511
+ try {
2512
+ const { workId } = await runReviewReadyWorkflow(projectRoot, target);
2513
+ return {
2514
+ exitCode: 0,
2515
+ stderr: "",
2516
+ stdout: `\uB9AC\uBDF0 \uC900\uBE44 \uC644\uB8CC: ${workId}`
2517
+ };
2518
+ } catch (error) {
2519
+ return {
2520
+ exitCode: 1,
2521
+ stderr: error instanceof Error ? error.message : "Unknown review ready failure.",
2522
+ stdout: ""
2523
+ };
2524
+ }
2525
+ }
2526
+
1853
2527
  // src/commands/spec-approve.ts
1854
2528
  import { checkbox as checkbox3 } from "@inquirer/prompts";
1855
2529
  function formatTaskLabel2(task) {
@@ -1908,17 +2582,21 @@ async function runSpecApproveCommand(input, projectRoot) {
1908
2582
  }
1909
2583
 
1910
2584
  // src/commands/start.ts
1911
- async function runStartCommand(type, slug, projectRoot) {
2585
+ async function runStartCommand(type, slug, projectRoot, options) {
1912
2586
  try {
1913
- const result = await startTask(type, slug, projectRoot);
2587
+ const result = await startTask(type, slug, projectRoot, /* @__PURE__ */ new Date(), options);
2588
+ const lines = [
2589
+ "\uC791\uC5C5 \uB514\uB809\uD1A0\uB9AC \uC0DD\uC131\uB428",
2590
+ `\uACBD\uB85C: ${result.workspacePath}/`,
2591
+ `\uC0C1\uD0DC: ${result.status}`
2592
+ ];
2593
+ if (result.gitignoreWarning !== void 0) {
2594
+ lines.push("", result.gitignoreWarning);
2595
+ }
1914
2596
  return {
1915
2597
  exitCode: 0,
1916
2598
  stderr: "",
1917
- stdout: [
1918
- "\uC791\uC5C5 \uB514\uB809\uD1A0\uB9AC \uC0DD\uC131\uB428",
1919
- `\uACBD\uB85C: ${result.workspacePath}/`,
1920
- `\uC0C1\uD0DC: ${result.status}`
1921
- ].join("\n")
2599
+ stdout: lines.join("\n")
1922
2600
  };
1923
2601
  } catch (error) {
1924
2602
  return {
@@ -1929,10 +2607,57 @@ async function runStartCommand(type, slug, projectRoot) {
1929
2607
  }
1930
2608
  }
1931
2609
 
2610
+ // src/core/use.ts
2611
+ async function resolveUseTarget(projectRoot, target) {
2612
+ const tasks = await listWorkspaceTasks(projectRoot);
2613
+ const trimmed = target.trim();
2614
+ const idMatch = tasks.find((task) => task.id === trimmed);
2615
+ if (idMatch !== void 0) {
2616
+ return idMatch;
2617
+ }
2618
+ const slugMatches = tasks.filter((task) => task.slug === trimmed);
2619
+ if (slugMatches.length === 1) {
2620
+ const match = slugMatches[0];
2621
+ if (match !== void 0) {
2622
+ return match;
2623
+ }
2624
+ }
2625
+ if (slugMatches.length > 1) {
2626
+ const candidates = slugMatches.map((task) => task.id).join(", ");
2627
+ throw new Error(
2628
+ `Multiple works match slug '${trimmed}': ${candidates}. Use \`sduck use <id>\` to specify.`
2629
+ );
2630
+ }
2631
+ throw new Error(`No work matches '${trimmed}'.`);
2632
+ }
2633
+ async function runUseWorkflow(projectRoot, target) {
2634
+ const work = await resolveUseTarget(projectRoot, target);
2635
+ await writeCurrentWorkId(projectRoot, work.id);
2636
+ return { workId: work.id };
2637
+ }
2638
+
2639
+ // src/commands/use.ts
2640
+ async function runUseCommand(target, projectRoot) {
2641
+ try {
2642
+ const { workId } = await runUseWorkflow(projectRoot, target);
2643
+ return {
2644
+ exitCode: 0,
2645
+ stderr: "",
2646
+ stdout: `\uD604\uC7AC \uC791\uC5C5 \uC804\uD658: ${workId}`
2647
+ };
2648
+ } catch (error) {
2649
+ return {
2650
+ exitCode: 1,
2651
+ stderr: error instanceof Error ? error.message : "Unknown use failure.",
2652
+ stdout: ""
2653
+ };
2654
+ }
2655
+ }
2656
+
1932
2657
  // package.json
1933
2658
  var package_default = {
1934
2659
  name: "@sduck/sduck-cli",
1935
- version: "0.1.8",
2660
+ version: "0.2.0",
1936
2661
  description: "Spec-Driven Development CLI bootstrap",
1937
2662
  type: "module",
1938
2663
  bin: {
@@ -2021,8 +2746,9 @@ program.command("init").description("Initialize the current repository for the S
2021
2746
  program.command("preview <name>").description("Print the normalized command name for bootstrap verification").action((name) => {
2022
2747
  console.log(normalizeCommandName(name));
2023
2748
  });
2024
- program.command("start <type> <slug>").description("Create a new task workspace from a type template").action(async (type, slug) => {
2025
- const result = await runStartCommand(type, slug, process.cwd());
2749
+ program.command("start <type> <slug>").description("Create a new task workspace from a type template").option("--no-git", "Skip git worktree creation").action(async (type, slug, options) => {
2750
+ const startOptions = options.git ? void 0 : { noGit: true };
2751
+ const result = await runStartCommand(type, slug, process.cwd(), startOptions);
2026
2752
  if (result.stdout !== "") {
2027
2753
  console.log(result.stdout);
2028
2754
  }
@@ -2033,8 +2759,9 @@ program.command("start <type> <slug>").description("Create a new task workspace
2033
2759
  process.exitCode = result.exitCode;
2034
2760
  }
2035
2761
  });
2036
- program.command("fast-track <type> <slug>").description("Create a minimal spec/plan task with optional bundled approval").action(async (type, slug) => {
2037
- const result = await runFastTrackCommand({ slug, type }, process.cwd());
2762
+ program.command("fast-track <type> <slug>").description("Create a minimal spec/plan task with optional bundled approval").option("--no-git", "Skip git worktree creation").action(async (type, slug, options) => {
2763
+ const startOptions = options.git ? void 0 : { noGit: true };
2764
+ const result = await runFastTrackCommand({ slug, type }, process.cwd(), startOptions);
2038
2765
  if (result.stdout !== "") {
2039
2766
  console.log(result.stdout);
2040
2767
  }
@@ -2110,6 +2837,67 @@ program.command("archive").description("Archive completed tasks into monthly dir
2110
2837
  process.exitCode = result.exitCode;
2111
2838
  }
2112
2839
  });
2840
+ program.command("use <target>").description("Switch the current active work").action(async (target) => {
2841
+ const result = await runUseCommand(target, process.cwd());
2842
+ if (result.stdout !== "") {
2843
+ console.log(result.stdout);
2844
+ }
2845
+ if (result.stderr !== "") {
2846
+ console.error(result.stderr);
2847
+ }
2848
+ if (result.exitCode !== 0) {
2849
+ process.exitCode = result.exitCode;
2850
+ }
2851
+ });
2852
+ program.command("implement").description("Show implementation context for the current work").action(async () => {
2853
+ const result = await runImplementCommand(process.cwd());
2854
+ if (result.stdout !== "") {
2855
+ console.log(result.stdout);
2856
+ }
2857
+ if (result.stderr !== "") {
2858
+ console.error(result.stderr);
2859
+ }
2860
+ if (result.exitCode !== 0) {
2861
+ process.exitCode = result.exitCode;
2862
+ }
2863
+ });
2864
+ program.command("abandon <target>").description("Abandon an active work").action(async (target) => {
2865
+ const result = await runAbandonCommand(target, process.cwd());
2866
+ if (result.stdout !== "") {
2867
+ console.log(result.stdout);
2868
+ }
2869
+ if (result.stderr !== "") {
2870
+ console.error(result.stderr);
2871
+ }
2872
+ if (result.exitCode !== 0) {
2873
+ process.exitCode = result.exitCode;
2874
+ }
2875
+ });
2876
+ var reviewCmd = program.command("review").description("Manage review workflow state");
2877
+ reviewCmd.command("ready [target]").description("Mark a work as review ready").action(async (target) => {
2878
+ const result = await runReviewReadyCommand(target, process.cwd());
2879
+ if (result.stdout !== "") {
2880
+ console.log(result.stdout);
2881
+ }
2882
+ if (result.stderr !== "") {
2883
+ console.error(result.stderr);
2884
+ }
2885
+ if (result.exitCode !== 0) {
2886
+ process.exitCode = result.exitCode;
2887
+ }
2888
+ });
2889
+ program.command("clean [target]").description("Clean up completed or abandoned works").option("--force", "Force delete unmerged branches").action(async (target, options) => {
2890
+ const result = await runCleanCommand({ force: options.force, target }, process.cwd());
2891
+ if (result.stdout !== "") {
2892
+ console.log(result.stdout);
2893
+ }
2894
+ if (result.stderr !== "") {
2895
+ console.error(result.stderr);
2896
+ }
2897
+ if (result.exitCode !== 0) {
2898
+ process.exitCode = result.exitCode;
2899
+ }
2900
+ });
2113
2901
  program.command("roadmap").description("Show the current bootstrap status").action(() => {
2114
2902
  console.log(PLACEHOLDER_MESSAGE);
2115
2903
  });