@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/.sduck/sduck-assets/agent-rules/core.md +18 -9
- package/README.md +136 -34
- package/dist/cli.js +986 -198
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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/
|
|
7
|
-
import { readFile as
|
|
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
|
-
|
|
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
|
|
75
|
-
const idMatch = /^id
|
|
76
|
-
const slugMatch = /^slug
|
|
77
|
-
const statusMatch = /^status
|
|
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
|
|
109
|
-
if (
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
174
|
-
const metaContent = await
|
|
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 =
|
|
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 =
|
|
208
|
-
const destPath =
|
|
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/
|
|
268
|
-
import { readFile as
|
|
269
|
-
import { join as
|
|
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/
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 === "
|
|
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
|
|
326
|
-
const completedMatch = /^ {2}completed
|
|
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 !== "
|
|
369
|
-
throw new Error(
|
|
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 =
|
|
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
|
|
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 =
|
|
387
|
-
const specPath =
|
|
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
|
|
763
|
+
const reviewWarning = await checkReviewWarning(projectRoot, task);
|
|
764
|
+
const metaContent = await readFile6(metaPath, "utf8");
|
|
395
765
|
validateDoneMetaContent(metaContent);
|
|
396
|
-
const specContent = await
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
810
|
+
const currentWorkId = await readCurrentWorkId(projectRoot);
|
|
811
|
+
if (currentWorkId === null) {
|
|
812
|
+
throwNoCurrentWorkError("done");
|
|
439
813
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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
|
|
543
|
-
import { join as
|
|
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
|
|
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:
|
|
557
|
-
plan:
|
|
558
|
-
spec:
|
|
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:
|
|
562
|
-
feature:
|
|
563
|
-
fix:
|
|
564
|
-
refactor:
|
|
565
|
-
chore:
|
|
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
|
-
|
|
577
|
-
|
|
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
|
|
595
|
-
import { join as
|
|
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 =
|
|
638
|
-
const planPath =
|
|
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
|
|
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
|
|
1034
|
+
await readFile7(metaPath, "utf8"),
|
|
655
1035
|
approvedAt,
|
|
656
1036
|
totalSteps
|
|
657
1037
|
);
|
|
658
|
-
await
|
|
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
|
-
|
|
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
|
|
678
|
-
import { join as
|
|
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 =
|
|
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
|
|
718
|
-
await
|
|
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
|
-
|
|
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
|
|
736
|
-
import { join as
|
|
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
|
|
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
|
|
796
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
823
|
-
await
|
|
824
|
-
await
|
|
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(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
901
|
-
await
|
|
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
|
|
1031
|
-
import { dirname as dirname2, join as
|
|
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 =
|
|
1034
|
-
var CLAUDE_CODE_HOOK_SCRIPT_PATH =
|
|
1035
|
-
var CLAUDE_CODE_HOOK_SOURCE_PATH =
|
|
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:
|
|
1624
|
+
outputPath: join14(".cursor", "rules", "sduck-core.mdc"),
|
|
1057
1625
|
kind: "managed-file"
|
|
1058
1626
|
},
|
|
1059
1627
|
{
|
|
1060
1628
|
agentId: "antigravity",
|
|
1061
|
-
outputPath:
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
|
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
|
|
1196
|
-
import { dirname as dirname3, join as
|
|
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(
|
|
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 =
|
|
1885
|
+
const targetPath = join15(projectRoot, target.outputPath);
|
|
1318
1886
|
if (await getFsEntryKind(targetPath) === "file") {
|
|
1319
|
-
contents.set(target.outputPath, await
|
|
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 =
|
|
1383
|
-
const targetPath =
|
|
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(
|
|
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 =
|
|
1418
|
-
const hookTargetPath =
|
|
1419
|
-
const settingsPath =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1671
|
-
import { join as
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1792
|
-
const metaPath =
|
|
1793
|
-
const metaContent = await
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
});
|