@sduck/sduck-cli 0.1.1 → 0.1.2
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/README.md +148 -0
- package/dist/cli.js +1135 -566
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/sduck-assets/agent-rules/antigravity.md +0 -4
- package/sduck-assets/agent-rules/claude-code.md +0 -5
- package/sduck-assets/agent-rules/codex.md +0 -6
- package/sduck-assets/agent-rules/core.md +0 -13
- package/sduck-assets/agent-rules/cursor.mdc +0 -11
- package/sduck-assets/agent-rules/gemini-cli.md +0 -6
- package/sduck-assets/agent-rules/opencode.md +0 -6
- package/sduck-assets/eval/plan.yml +0 -31
- package/sduck-assets/eval/spec.yml +0 -31
- package/sduck-assets/eval/task.yml +0 -31
- package/sduck-assets/types/build.md +0 -194
- package/sduck-assets/types/chore.md +0 -164
- package/sduck-assets/types/feature.md +0 -187
- package/sduck-assets/types/fix.md +0 -174
- package/sduck-assets/types/refactor.md +0 -174
package/dist/cli.js
CHANGED
|
@@ -3,42 +3,906 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
import {
|
|
6
|
+
// src/core/done.ts
|
|
7
|
+
import { readFile as readFile2, writeFile } from "fs/promises";
|
|
8
|
+
import { join as join3 } from "path";
|
|
9
|
+
|
|
10
|
+
// src/core/fs.ts
|
|
11
|
+
import { constants } from "fs";
|
|
12
|
+
import { access, copyFile, mkdir, stat } from "fs/promises";
|
|
13
|
+
async function getFsEntryKind(targetPath) {
|
|
14
|
+
try {
|
|
15
|
+
const stats = await stat(targetPath);
|
|
16
|
+
if (stats.isDirectory()) {
|
|
17
|
+
return "directory";
|
|
18
|
+
}
|
|
19
|
+
if (stats.isFile()) {
|
|
20
|
+
return "file";
|
|
21
|
+
}
|
|
22
|
+
return "file";
|
|
23
|
+
} catch {
|
|
24
|
+
return "missing";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function ensureDirectory(targetPath) {
|
|
28
|
+
await mkdir(targetPath, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
async function ensureReadableFile(targetPath) {
|
|
31
|
+
await access(targetPath, constants.R_OK);
|
|
32
|
+
}
|
|
33
|
+
async function copyFileIntoPlace(sourcePath, targetPath) {
|
|
34
|
+
await copyFile(sourcePath, targetPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/core/project-paths.ts
|
|
38
|
+
import { join, relative } from "path";
|
|
39
|
+
var SDUCK_HOME_DIR = ".sduck";
|
|
40
|
+
var SDUCK_ASSETS_DIR = "sduck-assets";
|
|
41
|
+
var SDUCK_WORKSPACE_DIR = "sduck-workspace";
|
|
42
|
+
var PROJECT_SDUCK_HOME_RELATIVE_PATH = SDUCK_HOME_DIR;
|
|
43
|
+
var PROJECT_SDUCK_ASSETS_RELATIVE_PATH = join(SDUCK_HOME_DIR, SDUCK_ASSETS_DIR);
|
|
44
|
+
var PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH = join(SDUCK_HOME_DIR, SDUCK_WORKSPACE_DIR);
|
|
45
|
+
function getProjectSduckHomePath(projectRoot) {
|
|
46
|
+
return join(projectRoot, PROJECT_SDUCK_HOME_RELATIVE_PATH);
|
|
47
|
+
}
|
|
48
|
+
function getProjectSduckAssetsPath(projectRoot) {
|
|
49
|
+
return join(projectRoot, PROJECT_SDUCK_ASSETS_RELATIVE_PATH);
|
|
50
|
+
}
|
|
51
|
+
function getProjectSduckWorkspacePath(projectRoot) {
|
|
52
|
+
return join(projectRoot, PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH);
|
|
53
|
+
}
|
|
54
|
+
function getProjectRelativeSduckAssetPath(...segments) {
|
|
55
|
+
return join(PROJECT_SDUCK_ASSETS_RELATIVE_PATH, ...segments);
|
|
56
|
+
}
|
|
57
|
+
function getProjectRelativeSduckWorkspacePath(...segments) {
|
|
58
|
+
return join(PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH, ...segments);
|
|
59
|
+
}
|
|
60
|
+
function toBundledAssetRelativePath(projectRelativeAssetPath) {
|
|
61
|
+
return relative(PROJECT_SDUCK_ASSETS_RELATIVE_PATH, projectRelativeAssetPath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/core/workspace.ts
|
|
65
|
+
import { readFile } from "fs/promises";
|
|
66
|
+
import { join as join2 } from "path";
|
|
67
|
+
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["IN_PROGRESS", "PENDING_SPEC_APPROVAL", "PENDING_PLAN_APPROVAL"]);
|
|
68
|
+
function parseMetaText(content) {
|
|
69
|
+
const createdAtMatch = /^created_at:\s+(.+)$/m.exec(content);
|
|
70
|
+
const idMatch = /^id:\s+(.+)$/m.exec(content);
|
|
71
|
+
const slugMatch = /^slug:\s+(.+)$/m.exec(content);
|
|
72
|
+
const statusMatch = /^status:\s+(.+)$/m.exec(content);
|
|
73
|
+
const parsedMeta = {};
|
|
74
|
+
if (createdAtMatch?.[1] !== void 0) {
|
|
75
|
+
parsedMeta.createdAt = createdAtMatch[1].trim();
|
|
76
|
+
}
|
|
77
|
+
if (idMatch?.[1] !== void 0) {
|
|
78
|
+
parsedMeta.id = idMatch[1].trim();
|
|
79
|
+
}
|
|
80
|
+
if (slugMatch?.[1] !== void 0) {
|
|
81
|
+
parsedMeta.slug = slugMatch[1].trim();
|
|
82
|
+
}
|
|
83
|
+
if (statusMatch?.[1] !== void 0) {
|
|
84
|
+
parsedMeta.status = statusMatch[1].trim();
|
|
85
|
+
}
|
|
86
|
+
return parsedMeta;
|
|
87
|
+
}
|
|
88
|
+
function sortTasksByRecency(tasks) {
|
|
89
|
+
return [...tasks].sort((left, right) => {
|
|
90
|
+
const leftValue = left.createdAt ?? "";
|
|
91
|
+
const rightValue = right.createdAt ?? "";
|
|
92
|
+
return rightValue.localeCompare(leftValue);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function listWorkspaceTasks(projectRoot) {
|
|
96
|
+
const workspaceRoot = getProjectSduckWorkspacePath(projectRoot);
|
|
97
|
+
if (await getFsEntryKind(workspaceRoot) !== "directory") {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const { readdir } = await import("fs/promises");
|
|
101
|
+
const entries = await readdir(workspaceRoot, { withFileTypes: true });
|
|
102
|
+
const tasks = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isDirectory()) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const relativePath = getProjectRelativeSduckWorkspacePath(entry.name);
|
|
108
|
+
const metaPath = join2(projectRoot, relativePath, "meta.yml");
|
|
109
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const parsedMeta = parseMetaText(await readFile(metaPath, "utf8"));
|
|
113
|
+
if (parsedMeta.id !== void 0 && parsedMeta.status !== void 0) {
|
|
114
|
+
const task = {
|
|
115
|
+
id: parsedMeta.id,
|
|
116
|
+
path: relativePath,
|
|
117
|
+
status: parsedMeta.status
|
|
118
|
+
};
|
|
119
|
+
if (parsedMeta.createdAt !== void 0) {
|
|
120
|
+
task.createdAt = parsedMeta.createdAt;
|
|
121
|
+
}
|
|
122
|
+
if (parsedMeta.slug !== void 0) {
|
|
123
|
+
task.slug = parsedMeta.slug;
|
|
124
|
+
}
|
|
125
|
+
tasks.push(task);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return sortTasksByRecency(tasks);
|
|
129
|
+
}
|
|
130
|
+
async function findActiveTask(projectRoot) {
|
|
131
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
132
|
+
for (const task of tasks) {
|
|
133
|
+
if (ACTIVE_STATUSES.has(task.status)) {
|
|
134
|
+
return {
|
|
135
|
+
id: task.id,
|
|
136
|
+
path: task.path,
|
|
137
|
+
status: task.status
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/utils/utc-date.ts
|
|
145
|
+
function pad2(value) {
|
|
146
|
+
return String(value).padStart(2, "0");
|
|
147
|
+
}
|
|
148
|
+
function formatUtcDate(date) {
|
|
149
|
+
const year = String(date.getUTCFullYear());
|
|
150
|
+
const month = pad2(date.getUTCMonth() + 1);
|
|
151
|
+
const day = pad2(date.getUTCDate());
|
|
152
|
+
return `${year}-${month}-${day}`;
|
|
153
|
+
}
|
|
154
|
+
function formatUtcTimestamp(date) {
|
|
155
|
+
const year = String(date.getUTCFullYear());
|
|
156
|
+
const month = pad2(date.getUTCMonth() + 1);
|
|
157
|
+
const day = pad2(date.getUTCDate());
|
|
158
|
+
const hour = pad2(date.getUTCHours());
|
|
159
|
+
const minute = pad2(date.getUTCMinutes());
|
|
160
|
+
const second = pad2(date.getUTCSeconds());
|
|
161
|
+
return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/core/done.ts
|
|
165
|
+
var TASK_EVAL_ASSET_PATH = getProjectRelativeSduckAssetPath("eval", "task.yml");
|
|
166
|
+
function filterDoneCandidates(tasks) {
|
|
167
|
+
return tasks.filter((task) => task.status === "IN_PROGRESS");
|
|
168
|
+
}
|
|
169
|
+
function resolveDoneTargetMatches(tasks, target) {
|
|
170
|
+
if (target === void 0 || target.trim() === "") {
|
|
171
|
+
return filterDoneCandidates(tasks);
|
|
172
|
+
}
|
|
173
|
+
const trimmedTarget = target.trim();
|
|
174
|
+
return tasks.filter((task) => task.id === trimmedTarget || task.slug === trimmedTarget);
|
|
175
|
+
}
|
|
176
|
+
function extractUncheckedChecklistItems(specContent) {
|
|
177
|
+
const uncheckedMatches = specContent.matchAll(/^\s*- \[ \] (.+)$/gm);
|
|
178
|
+
return [...uncheckedMatches].map((match) => match[1]).filter((item) => item !== void 0).map((item) => item.trim());
|
|
179
|
+
}
|
|
180
|
+
function extractTaskEvalCriteriaLabels(taskEvalContent) {
|
|
181
|
+
const labels = [...taskEvalContent.matchAll(/^\s{6}label:\s+(.+)$/gm)].map((match) => match[1]).filter((item) => item !== void 0).map((item) => item.trim());
|
|
182
|
+
return [...new Set(labels)];
|
|
183
|
+
}
|
|
184
|
+
function parseCompletedStepNumbers(value) {
|
|
185
|
+
const trimmedValue = value.trim();
|
|
186
|
+
if (trimmedValue === "") {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
return trimmedValue.split(",").map((segment) => {
|
|
190
|
+
const parsedValue = Number.parseInt(segment.trim(), 10);
|
|
191
|
+
if (!Number.isInteger(parsedValue)) {
|
|
192
|
+
throw new Error(`Invalid completed step value: ${segment.trim()}`);
|
|
193
|
+
}
|
|
194
|
+
return parsedValue;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function validateDoneMetaContent(metaContent) {
|
|
198
|
+
const totalMatch = /^ {2}total:\s+(.+)$/m.exec(metaContent);
|
|
199
|
+
const completedMatch = /^ {2}completed:\s+\[(.*)\]$/m.exec(metaContent);
|
|
200
|
+
if (totalMatch?.[1] === void 0 || completedMatch?.[1] === void 0) {
|
|
201
|
+
throw new Error("Task meta is missing a valid steps block.");
|
|
202
|
+
}
|
|
203
|
+
if (totalMatch[1].trim() === "null") {
|
|
204
|
+
throw new Error("Task steps are not initialized yet (steps.total is null).");
|
|
205
|
+
}
|
|
206
|
+
const totalSteps = Number.parseInt(totalMatch[1].trim(), 10);
|
|
207
|
+
if (!Number.isInteger(totalSteps) || totalSteps <= 0) {
|
|
208
|
+
throw new Error(`Task has an invalid steps.total value: ${totalMatch[1].trim()}`);
|
|
209
|
+
}
|
|
210
|
+
const completedSteps = parseCompletedStepNumbers(completedMatch[1]);
|
|
211
|
+
const uniqueSteps = new Set(completedSteps);
|
|
212
|
+
if (uniqueSteps.size !== completedSteps.length) {
|
|
213
|
+
throw new Error("Task has duplicate completed step numbers.");
|
|
214
|
+
}
|
|
215
|
+
const invalidStep = completedSteps.find((step) => step < 1 || step > totalSteps);
|
|
216
|
+
if (invalidStep !== void 0) {
|
|
217
|
+
throw new Error(`Task has an out-of-range completed step number: ${String(invalidStep)}`);
|
|
218
|
+
}
|
|
219
|
+
if (completedSteps.length !== totalSteps) {
|
|
220
|
+
const missingSteps = [];
|
|
221
|
+
for (let step = 1; step <= totalSteps; step += 1) {
|
|
222
|
+
if (!uniqueSteps.has(step)) {
|
|
223
|
+
missingSteps.push(step);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`Task steps are incomplete. Missing steps: ${missingSteps.join(", ")}`);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
completedSteps,
|
|
230
|
+
totalSteps
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function updateDoneBlock(metaContent, completedAt) {
|
|
234
|
+
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: DONE");
|
|
235
|
+
return withStatus.replace(/^completed_at:\s+.+$/m, `completed_at: ${completedAt}`);
|
|
236
|
+
}
|
|
237
|
+
function validateDoneTarget(task) {
|
|
238
|
+
if (task.status === "DONE") {
|
|
239
|
+
throw new Error(`Task ${task.id} is already DONE.`);
|
|
240
|
+
}
|
|
241
|
+
if (task.status !== "IN_PROGRESS") {
|
|
242
|
+
throw new Error(`Task ${task.id} is not in progress (${task.status}).`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function loadTaskEvalCriteria(projectRoot) {
|
|
246
|
+
const taskEvalPath = join3(projectRoot, TASK_EVAL_ASSET_PATH);
|
|
247
|
+
if (await getFsEntryKind(taskEvalPath) !== "file") {
|
|
248
|
+
throw new Error(`Missing task evaluation asset at ${TASK_EVAL_ASSET_PATH}.`);
|
|
249
|
+
}
|
|
250
|
+
const taskEvalContent = await readFile2(taskEvalPath, "utf8");
|
|
251
|
+
const labels = extractTaskEvalCriteriaLabels(taskEvalContent);
|
|
252
|
+
if (labels.length === 0) {
|
|
253
|
+
throw new Error(`Task evaluation asset has no criteria labels: ${TASK_EVAL_ASSET_PATH}.`);
|
|
254
|
+
}
|
|
255
|
+
return labels;
|
|
256
|
+
}
|
|
257
|
+
async function completeTask(projectRoot, task, completedAt, taskEvalCriteria) {
|
|
258
|
+
validateDoneTarget(task);
|
|
259
|
+
const metaPath = join3(projectRoot, task.path, "meta.yml");
|
|
260
|
+
const specPath = join3(projectRoot, task.path, "spec.md");
|
|
261
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
262
|
+
throw new Error(`Missing meta.yml for task ${task.id}.`);
|
|
263
|
+
}
|
|
264
|
+
if (await getFsEntryKind(specPath) !== "file") {
|
|
265
|
+
throw new Error(`Missing spec.md for task ${task.id}.`);
|
|
266
|
+
}
|
|
267
|
+
const metaContent = await readFile2(metaPath, "utf8");
|
|
268
|
+
validateDoneMetaContent(metaContent);
|
|
269
|
+
const specContent = await readFile2(specPath, "utf8");
|
|
270
|
+
const uncheckedItems = extractUncheckedChecklistItems(specContent);
|
|
271
|
+
if (uncheckedItems.length > 0) {
|
|
272
|
+
throw new Error(`Spec checklist is incomplete: ${uncheckedItems.join("; ")}`);
|
|
273
|
+
}
|
|
274
|
+
await writeFile(metaPath, updateDoneBlock(metaContent, completedAt), "utf8");
|
|
275
|
+
return {
|
|
276
|
+
completedAt,
|
|
277
|
+
note: `task eval checked (${String(taskEvalCriteria.length)} criteria)`,
|
|
278
|
+
taskEvalCriteria: [...taskEvalCriteria],
|
|
279
|
+
taskId: task.id
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function runDoneWorkflow(projectRoot, tasks, completedAt) {
|
|
283
|
+
const taskEvalCriteria = await loadTaskEvalCriteria(projectRoot);
|
|
284
|
+
const succeeded = [];
|
|
285
|
+
const failed = [];
|
|
286
|
+
for (const task of tasks) {
|
|
287
|
+
try {
|
|
288
|
+
succeeded.push(await completeTask(projectRoot, task, completedAt, taskEvalCriteria));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const message = error instanceof Error ? error.message : "Unknown done failure.";
|
|
291
|
+
const pendingChecklistItems = message.startsWith("Spec checklist is incomplete: ") ? message.replace("Spec checklist is incomplete: ", "").split("; ").filter((item) => item !== "") : [];
|
|
292
|
+
failed.push({
|
|
293
|
+
note: message,
|
|
294
|
+
pendingChecklistItems,
|
|
295
|
+
taskId: task.id
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
completedAt,
|
|
301
|
+
failed,
|
|
302
|
+
nextStatus: "DONE",
|
|
303
|
+
succeeded
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async function loadDoneTargets(projectRoot, input) {
|
|
307
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
308
|
+
const matches = resolveDoneTargetMatches(tasks, input.target);
|
|
309
|
+
if (input.target === void 0 || input.target.trim() === "") {
|
|
310
|
+
if (matches.length === 0) {
|
|
311
|
+
throw new Error("No IN_PROGRESS task found. Run `sduck done <slug>` after choosing a task.");
|
|
312
|
+
}
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
const labels = matches.map((task) => task.slug ?? task.id).join(", ");
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Multiple IN_PROGRESS tasks found: ${labels}. Rerun with \`sduck done <slug>\` or \`sduck done <id>\`.`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return matches;
|
|
320
|
+
}
|
|
321
|
+
if (matches.length === 0) {
|
|
322
|
+
throw new Error(`No task matches target '${input.target.trim()}'.`);
|
|
323
|
+
}
|
|
324
|
+
if (matches.length > 1) {
|
|
325
|
+
const ids = matches.map((task) => task.id).join(", ");
|
|
326
|
+
throw new Error(`Multiple tasks match '${input.target.trim()}': ${ids}. Use an exact task id.`);
|
|
327
|
+
}
|
|
328
|
+
return matches;
|
|
329
|
+
}
|
|
330
|
+
function createTaskCompletedAt(date = /* @__PURE__ */ new Date()) {
|
|
331
|
+
return formatUtcTimestamp(date);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/commands/done.ts
|
|
335
|
+
function padCell(value, width) {
|
|
336
|
+
return value.padEnd(width, " ");
|
|
337
|
+
}
|
|
338
|
+
function buildResultTable(result) {
|
|
339
|
+
const rows = [
|
|
340
|
+
...result.succeeded.map((row) => ({
|
|
341
|
+
note: row.note,
|
|
342
|
+
result: "success",
|
|
343
|
+
task: row.taskId
|
|
344
|
+
})),
|
|
345
|
+
...result.failed.map((row) => ({
|
|
346
|
+
note: row.note,
|
|
347
|
+
result: "failed",
|
|
348
|
+
task: row.taskId
|
|
349
|
+
}))
|
|
350
|
+
];
|
|
351
|
+
const resultWidth = Math.max("Result".length, ...rows.map((row) => row.result.length));
|
|
352
|
+
const taskWidth = Math.max("Task".length, ...rows.map((row) => row.task.length));
|
|
353
|
+
const noteWidth = Math.max("Note".length, ...rows.map((row) => row.note.length));
|
|
354
|
+
const border = `+-${"-".repeat(resultWidth)}-+-${"-".repeat(taskWidth)}-+-${"-".repeat(noteWidth)}-+`;
|
|
355
|
+
const header = `| ${padCell("Result", resultWidth)} | ${padCell("Task", taskWidth)} | ${padCell("Note", noteWidth)} |`;
|
|
356
|
+
const body = rows.map(
|
|
357
|
+
(row) => `| ${padCell(row.result, resultWidth)} | ${padCell(row.task, taskWidth)} | ${padCell(row.note, noteWidth)} |`
|
|
358
|
+
);
|
|
359
|
+
return [border, header, border, ...body, border].join("\n");
|
|
360
|
+
}
|
|
361
|
+
function formatFailureDetails(failed) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
for (const row of failed) {
|
|
364
|
+
if (row.pendingChecklistItems.length === 0) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
lines.push("", `\uBBF8\uC644\uB8CC \uCCB4\uD06C\uB9AC\uC2A4\uD2B8 (${row.taskId})`);
|
|
368
|
+
for (const item of row.pendingChecklistItems) {
|
|
369
|
+
lines.push(`- [ ] ${item}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return lines;
|
|
373
|
+
}
|
|
374
|
+
function formatSuccess(result) {
|
|
375
|
+
const lines = [buildResultTable(result)];
|
|
376
|
+
if (result.succeeded.length > 0) {
|
|
377
|
+
const criteriaLabels = result.succeeded[0]?.taskEvalCriteria ?? [];
|
|
378
|
+
lines.push("", "\uC0C1\uD0DC: DONE");
|
|
379
|
+
if (criteriaLabels.length > 0) {
|
|
380
|
+
lines.push(`task eval \uAE30\uC900: ${criteriaLabels.join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
lines.push(...formatFailureDetails(result.failed));
|
|
384
|
+
return lines.join("\n");
|
|
385
|
+
}
|
|
386
|
+
async function runDoneCommand(input, projectRoot) {
|
|
387
|
+
try {
|
|
388
|
+
const tasks = await loadDoneTargets(projectRoot, input);
|
|
389
|
+
const result = await runDoneWorkflow(projectRoot, tasks, createTaskCompletedAt());
|
|
390
|
+
if (result.succeeded.length === 0) {
|
|
391
|
+
return {
|
|
392
|
+
exitCode: 1,
|
|
393
|
+
stderr: formatSuccess(result),
|
|
394
|
+
stdout: ""
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
exitCode: result.failed.length > 0 ? 1 : 0,
|
|
399
|
+
stderr: "",
|
|
400
|
+
stdout: formatSuccess(result)
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return {
|
|
404
|
+
exitCode: 1,
|
|
405
|
+
stderr: error instanceof Error ? error.message : "Unknown done failure.",
|
|
406
|
+
stdout: ""
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/commands/fast-track.ts
|
|
412
|
+
import { confirm } from "@inquirer/prompts";
|
|
413
|
+
|
|
414
|
+
// src/core/fast-track.ts
|
|
415
|
+
import { writeFile as writeFile5 } from "fs/promises";
|
|
416
|
+
import { join as join8 } from "path";
|
|
417
|
+
|
|
418
|
+
// src/core/assets.ts
|
|
419
|
+
import { dirname, join as join4 } from "path";
|
|
420
|
+
import { fileURLToPath } from "url";
|
|
421
|
+
var SUPPORTED_TASK_TYPES = [
|
|
422
|
+
"build",
|
|
423
|
+
"feature",
|
|
424
|
+
"fix",
|
|
425
|
+
"refactor",
|
|
426
|
+
"chore"
|
|
427
|
+
];
|
|
428
|
+
var EVAL_ASSET_RELATIVE_PATHS = {
|
|
429
|
+
task: join4("eval", "task.yml"),
|
|
430
|
+
plan: join4("eval", "plan.yml"),
|
|
431
|
+
spec: join4("eval", "spec.yml")
|
|
432
|
+
};
|
|
433
|
+
var SPEC_TEMPLATE_RELATIVE_PATHS = {
|
|
434
|
+
build: join4("types", "build.md"),
|
|
435
|
+
feature: join4("types", "feature.md"),
|
|
436
|
+
fix: join4("types", "fix.md"),
|
|
437
|
+
refactor: join4("types", "refactor.md"),
|
|
438
|
+
chore: join4("types", "chore.md")
|
|
439
|
+
};
|
|
440
|
+
var INIT_ASSET_RELATIVE_PATHS = [
|
|
441
|
+
EVAL_ASSET_RELATIVE_PATHS.spec,
|
|
442
|
+
EVAL_ASSET_RELATIVE_PATHS.plan,
|
|
443
|
+
EVAL_ASSET_RELATIVE_PATHS.task,
|
|
444
|
+
...Object.values(SPEC_TEMPLATE_RELATIVE_PATHS)
|
|
445
|
+
];
|
|
446
|
+
async function getBundledAssetsRoot() {
|
|
447
|
+
const currentDirectoryPath = dirname(fileURLToPath(import.meta.url));
|
|
448
|
+
const candidatePaths = [
|
|
449
|
+
join4(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets"),
|
|
450
|
+
join4(currentDirectoryPath, "..", ".sduck", "sduck-assets")
|
|
451
|
+
];
|
|
452
|
+
for (const candidatePath of candidatePaths) {
|
|
453
|
+
if (await getFsEntryKind(candidatePath) === "directory") {
|
|
454
|
+
return candidatePath;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
throw new Error("Unable to locate bundled .sduck/sduck-assets directory.");
|
|
458
|
+
}
|
|
459
|
+
function isSupportedTaskType(value) {
|
|
460
|
+
return SUPPORTED_TASK_TYPES.includes(value);
|
|
461
|
+
}
|
|
462
|
+
function resolveSpecTemplateRelativePath(type) {
|
|
463
|
+
return SPEC_TEMPLATE_RELATIVE_PATHS[type];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/core/plan-approve.ts
|
|
467
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
468
|
+
import { join as join5 } from "path";
|
|
469
|
+
function filterPlanApprovalCandidates(tasks) {
|
|
470
|
+
return tasks.filter((task) => task.status === "SPEC_APPROVED");
|
|
471
|
+
}
|
|
472
|
+
function resolvePlanApprovalCandidates(tasks, target) {
|
|
473
|
+
const candidates = filterPlanApprovalCandidates(tasks);
|
|
474
|
+
if (target === void 0 || target.trim() === "") {
|
|
475
|
+
return candidates;
|
|
476
|
+
}
|
|
477
|
+
const trimmedTarget = target.trim();
|
|
478
|
+
return candidates.filter((task) => task.id === trimmedTarget || task.slug === trimmedTarget);
|
|
479
|
+
}
|
|
480
|
+
function countPlanSteps(planContent) {
|
|
481
|
+
const matches = planContent.match(/^## Step \d+\. .+$/gm);
|
|
482
|
+
return matches?.length ?? 0;
|
|
483
|
+
}
|
|
484
|
+
function updatePlanApprovalBlock(metaContent, approvedAt, totalSteps) {
|
|
485
|
+
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: IN_PROGRESS");
|
|
486
|
+
const withPlan = withStatus.replace(
|
|
487
|
+
/plan:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
488
|
+
`plan:
|
|
489
|
+
approved: true
|
|
490
|
+
approved_at: ${approvedAt}`
|
|
491
|
+
);
|
|
492
|
+
return withPlan.replace(
|
|
493
|
+
/steps:\n {2}total:\s+null\n {2}completed:\s+\[\]/m,
|
|
494
|
+
`steps:
|
|
495
|
+
total: ${String(totalSteps)}
|
|
496
|
+
completed: []`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
async function approvePlans(projectRoot, tasks, approvedAt) {
|
|
500
|
+
const succeeded = [];
|
|
501
|
+
const failed = [];
|
|
502
|
+
for (const task of tasks) {
|
|
503
|
+
if (task.status !== "SPEC_APPROVED") {
|
|
504
|
+
failed.push({
|
|
505
|
+
note: `task is not awaiting plan approval (${task.status})`,
|
|
506
|
+
taskId: task.id
|
|
507
|
+
});
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const metaPath = join5(projectRoot, task.path, "meta.yml");
|
|
511
|
+
const planPath = join5(projectRoot, task.path, "plan.md");
|
|
512
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
513
|
+
failed.push({ note: "missing meta.yml", taskId: task.id });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (await getFsEntryKind(planPath) !== "file") {
|
|
517
|
+
failed.push({ note: "missing plan.md", taskId: task.id });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const planContent = await readFile3(planPath, "utf8");
|
|
521
|
+
const totalSteps = countPlanSteps(planContent);
|
|
522
|
+
if (totalSteps === 0) {
|
|
523
|
+
failed.push({ note: "missing valid Step headers", taskId: task.id });
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const updatedMeta = updatePlanApprovalBlock(
|
|
527
|
+
await readFile3(metaPath, "utf8"),
|
|
528
|
+
approvedAt,
|
|
529
|
+
totalSteps
|
|
530
|
+
);
|
|
531
|
+
await writeFile2(metaPath, updatedMeta, "utf8");
|
|
532
|
+
succeeded.push({ note: "moved to IN_PROGRESS", steps: totalSteps, taskId: task.id });
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
approvedAt,
|
|
536
|
+
failed,
|
|
537
|
+
nextStatus: "IN_PROGRESS",
|
|
538
|
+
succeeded
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
async function loadPlanApprovalCandidates(projectRoot, input) {
|
|
542
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
543
|
+
return resolvePlanApprovalCandidates(tasks, input.target);
|
|
544
|
+
}
|
|
545
|
+
function createPlanApprovedAt(date = /* @__PURE__ */ new Date()) {
|
|
546
|
+
return formatUtcTimestamp(date);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/core/spec-approve.ts
|
|
550
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
551
|
+
import { join as join6 } from "path";
|
|
552
|
+
function filterApprovalCandidates(tasks) {
|
|
553
|
+
return tasks.filter((task) => task.status === "PENDING_SPEC_APPROVAL");
|
|
554
|
+
}
|
|
555
|
+
function resolveTargetCandidates(tasks, target) {
|
|
556
|
+
const candidates = filterApprovalCandidates(tasks);
|
|
557
|
+
if (target === void 0 || target.trim() === "") {
|
|
558
|
+
return candidates;
|
|
559
|
+
}
|
|
560
|
+
const trimmedTarget = target.trim();
|
|
561
|
+
return candidates.filter((task) => task.id === trimmedTarget || task.slug === trimmedTarget);
|
|
562
|
+
}
|
|
563
|
+
function validateSpecApprovalTargets(tasks) {
|
|
564
|
+
if (tasks.length === 0) {
|
|
565
|
+
throw new Error("No approvable spec tasks found.");
|
|
566
|
+
}
|
|
567
|
+
const invalidTask = tasks.find((task) => task.status !== "PENDING_SPEC_APPROVAL");
|
|
568
|
+
if (invalidTask !== void 0) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Task ${invalidTask.id} is not awaiting spec approval (${invalidTask.status}).`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function updateSpecApprovalBlock(metaContent, approvedAt) {
|
|
575
|
+
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: SPEC_APPROVED");
|
|
576
|
+
return withStatus.replace(
|
|
577
|
+
/spec:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
578
|
+
`spec:
|
|
579
|
+
approved: true
|
|
580
|
+
approved_at: ${approvedAt}`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
async function approveSpecs(projectRoot, tasks, approvedAt) {
|
|
584
|
+
validateSpecApprovalTargets(tasks);
|
|
585
|
+
for (const task of tasks) {
|
|
586
|
+
const metaPath = join6(projectRoot, task.path, "meta.yml");
|
|
587
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
588
|
+
throw new Error(`Missing meta.yml for task ${task.id}.`);
|
|
589
|
+
}
|
|
590
|
+
const updatedContent = updateSpecApprovalBlock(await readFile4(metaPath, "utf8"), approvedAt);
|
|
591
|
+
await writeFile3(metaPath, updatedContent, "utf8");
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
approvedAt,
|
|
595
|
+
approvedTaskIds: tasks.map((task) => task.id),
|
|
596
|
+
nextStatus: "SPEC_APPROVED"
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function loadSpecApprovalCandidates(projectRoot, input) {
|
|
600
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
601
|
+
return resolveTargetCandidates(tasks, input.target);
|
|
602
|
+
}
|
|
603
|
+
function createSpecApprovedAt(date = /* @__PURE__ */ new Date()) {
|
|
604
|
+
return formatUtcTimestamp(date);
|
|
605
|
+
}
|
|
8
606
|
|
|
9
|
-
// src/core/
|
|
10
|
-
import { readFile } from "fs/promises";
|
|
11
|
-
import {
|
|
12
|
-
|
|
607
|
+
// src/core/start.ts
|
|
608
|
+
import { mkdir as mkdir2, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
609
|
+
import { join as join7 } from "path";
|
|
610
|
+
function normalizeSlug(input) {
|
|
611
|
+
return input.trim().toLowerCase().replace(/[_\s]+/g, "-").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
612
|
+
}
|
|
613
|
+
function validateSlug(slug) {
|
|
614
|
+
if (slug === "") {
|
|
615
|
+
throw new Error("Invalid slug: slug cannot be empty after normalization.");
|
|
616
|
+
}
|
|
617
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
618
|
+
throw new Error("Invalid slug: use lowercase kebab-case only.");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function createWorkspaceId(date, type, slug) {
|
|
622
|
+
const year = String(date.getUTCFullYear());
|
|
623
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
624
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
625
|
+
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
626
|
+
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
627
|
+
return `${year}${month}${day}-${hour}${minute}-${type}-${slug}`;
|
|
628
|
+
}
|
|
629
|
+
function renderInitialMeta(input) {
|
|
630
|
+
return [
|
|
631
|
+
`id: ${input.id}`,
|
|
632
|
+
`type: ${input.type}`,
|
|
633
|
+
`slug: ${input.slug}`,
|
|
634
|
+
`created_at: ${input.createdAt}`,
|
|
635
|
+
"",
|
|
636
|
+
"status: PENDING_SPEC_APPROVAL",
|
|
637
|
+
"",
|
|
638
|
+
"spec:",
|
|
639
|
+
" approved: false",
|
|
640
|
+
" approved_at: null",
|
|
641
|
+
"",
|
|
642
|
+
"plan:",
|
|
643
|
+
" approved: false",
|
|
644
|
+
" approved_at: null",
|
|
645
|
+
"",
|
|
646
|
+
"steps:",
|
|
647
|
+
" total: null",
|
|
648
|
+
" completed: []",
|
|
649
|
+
"",
|
|
650
|
+
"completed_at: null",
|
|
651
|
+
""
|
|
652
|
+
].join("\n");
|
|
653
|
+
}
|
|
654
|
+
async function resolveSpecTemplatePath(type) {
|
|
655
|
+
const assetsRoot = await getBundledAssetsRoot();
|
|
656
|
+
return join7(assetsRoot, resolveSpecTemplateRelativePath(type));
|
|
657
|
+
}
|
|
658
|
+
function applyTemplateDefaults(template, type, slug, currentDate) {
|
|
659
|
+
const displayName = slug.replace(/-/g, " ");
|
|
660
|
+
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}`);
|
|
661
|
+
}
|
|
662
|
+
async function startTask(rawType, rawSlug, projectRoot, currentDate = /* @__PURE__ */ new Date()) {
|
|
663
|
+
if (!isSupportedTaskType(rawType)) {
|
|
664
|
+
throw new Error(`Unsupported type: ${rawType}`);
|
|
665
|
+
}
|
|
666
|
+
const slug = normalizeSlug(rawSlug);
|
|
667
|
+
validateSlug(slug);
|
|
668
|
+
const activeTask = await findActiveTask(projectRoot);
|
|
669
|
+
if (activeTask !== null) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`Active task exists: ${activeTask.id} (${activeTask.status}) at ${activeTask.path}. Finish or approve it before starting a new task.`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const workspaceId = createWorkspaceId(currentDate, rawType, slug);
|
|
675
|
+
const workspacePath = getProjectRelativeSduckWorkspacePath(workspaceId);
|
|
676
|
+
const absoluteWorkspacePath = join7(projectRoot, workspacePath);
|
|
677
|
+
if (await getFsEntryKind(absoluteWorkspacePath) !== "missing") {
|
|
678
|
+
throw new Error(`Workspace already exists: ${workspacePath}`);
|
|
679
|
+
}
|
|
680
|
+
const workspaceRoot = getProjectSduckWorkspacePath(projectRoot);
|
|
681
|
+
await mkdir2(workspaceRoot, { recursive: true });
|
|
682
|
+
await mkdir2(absoluteWorkspacePath, { recursive: false });
|
|
683
|
+
const templatePath = await resolveSpecTemplatePath(rawType);
|
|
684
|
+
if (await getFsEntryKind(templatePath) !== "file") {
|
|
685
|
+
throw new Error(`Missing spec template for type '${rawType}' at ${templatePath}`);
|
|
686
|
+
}
|
|
687
|
+
const specTemplate = await readFile5(templatePath, "utf8");
|
|
688
|
+
const specContent = applyTemplateDefaults(specTemplate, rawType, slug, currentDate);
|
|
689
|
+
const metaContent = renderInitialMeta({
|
|
690
|
+
createdAt: formatUtcTimestamp(currentDate),
|
|
691
|
+
id: workspaceId,
|
|
692
|
+
slug,
|
|
693
|
+
type: rawType
|
|
694
|
+
});
|
|
695
|
+
await writeFile4(join7(absoluteWorkspacePath, "meta.yml"), metaContent, "utf8");
|
|
696
|
+
await writeFile4(join7(absoluteWorkspacePath, "spec.md"), specContent, "utf8");
|
|
697
|
+
await writeFile4(join7(absoluteWorkspacePath, "plan.md"), "", "utf8");
|
|
698
|
+
return {
|
|
699
|
+
workspaceId,
|
|
700
|
+
workspacePath,
|
|
701
|
+
status: "PENDING_SPEC_APPROVAL"
|
|
702
|
+
};
|
|
703
|
+
}
|
|
13
704
|
|
|
14
|
-
// src/core/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
705
|
+
// src/core/fast-track.ts
|
|
706
|
+
function toSpecApprovalTarget(target) {
|
|
707
|
+
return target;
|
|
708
|
+
}
|
|
709
|
+
function toPlanApprovalTarget(target) {
|
|
710
|
+
return {
|
|
711
|
+
...target,
|
|
712
|
+
status: "SPEC_APPROVED"
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function renderMinimalSpec(type, slug) {
|
|
716
|
+
const displayName = slug.replace(/-/g, " ");
|
|
717
|
+
return [
|
|
718
|
+
`# [${type}] ${displayName}`,
|
|
719
|
+
"",
|
|
720
|
+
"## \uBAA9\uD45C",
|
|
721
|
+
"",
|
|
722
|
+
`- ${displayName} \uC791\uC5C5\uC744 \uBE60\uB974\uAC8C \uC2DC\uC791\uD560 \uC218 \uC788\uB294 \uCD5C\uC18C \uC2A4\uD399\uC744 \uC815\uC758\uD55C\uB2E4`,
|
|
723
|
+
"",
|
|
724
|
+
"## \uBC94\uC704",
|
|
725
|
+
"",
|
|
726
|
+
`- ${displayName} \uAD6C\uD604\uC5D0 \uD544\uC694\uD55C \uD575\uC2EC \uBCC0\uACBD\uB9CC \uD3EC\uD568\uD55C\uB2E4`,
|
|
727
|
+
"",
|
|
728
|
+
"## \uC81C\uC678 \uBC94\uC704",
|
|
729
|
+
"",
|
|
730
|
+
"- \uC694\uAD6C\uC0AC\uD56D\uACFC \uC9C1\uC811 \uAD00\uB828 \uC5C6\uB294 \uB9AC\uD329\uD130\uB9C1\uC740 \uD3EC\uD568\uD558\uC9C0 \uC54A\uB294\uB2E4",
|
|
731
|
+
"",
|
|
732
|
+
"## \uC644\uB8CC \uC870\uAC74",
|
|
733
|
+
"",
|
|
734
|
+
"- [ ] \uD575\uC2EC \uB3D9\uC791\uC774 \uAD6C\uD604\uB41C\uB2E4",
|
|
735
|
+
"- [ ] \uAD00\uB828 \uD14C\uC2A4\uD2B8\uAC00 \uD1B5\uACFC\uD55C\uB2E4",
|
|
736
|
+
"- [ ] \uBB38\uC11C \uB610\uB294 \uC6CC\uD06C\uD50C\uB85C\uC6B0 \uC601\uD5A5\uC774 \uBC18\uC601\uB41C\uB2E4",
|
|
737
|
+
""
|
|
738
|
+
].join("\n");
|
|
739
|
+
}
|
|
740
|
+
function renderMinimalPlan(slug) {
|
|
741
|
+
const displayName = slug.replace(/-/g, " ");
|
|
742
|
+
return [
|
|
743
|
+
"# Plan",
|
|
744
|
+
"",
|
|
745
|
+
`## Step 1. ${displayName} \uC694\uAD6C\uC0AC\uD56D \uBC18\uC601`,
|
|
746
|
+
"",
|
|
747
|
+
"- \uD575\uC2EC \uAD6C\uD604 \uD30C\uC77C\uC744 \uC218\uC815\uD574 \uC694\uAD6C\uC0AC\uD56D\uC744 \uBC18\uC601\uD55C\uB2E4.",
|
|
748
|
+
"",
|
|
749
|
+
"## Step 2. \uAC80\uC99D\uACFC \uB9C8\uBB34\uB9AC",
|
|
750
|
+
"",
|
|
751
|
+
"- \uAD00\uB828 \uD14C\uC2A4\uD2B8\uC640 \uBB38\uC11C\uB97C \uC5C5\uB370\uC774\uD2B8\uD55C\uB2E4.",
|
|
752
|
+
"- `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`\uB85C \uAC80\uC99D\uD55C\uB2E4.",
|
|
753
|
+
""
|
|
754
|
+
].join("\n");
|
|
755
|
+
}
|
|
756
|
+
function isInteractiveApprovalAvailable() {
|
|
757
|
+
return process.stdin.isTTY && process.stdout.isTTY;
|
|
758
|
+
}
|
|
759
|
+
async function createFastTrackTask(input, projectRoot) {
|
|
760
|
+
if (!isSupportedTaskType(input.type)) {
|
|
761
|
+
throw new Error(`Unsupported type: ${input.type}`);
|
|
762
|
+
}
|
|
763
|
+
const startedTask = await startTask(input.type, input.slug, projectRoot);
|
|
764
|
+
const taskPath = join8(projectRoot, startedTask.workspacePath);
|
|
765
|
+
const specPath = join8(taskPath, "spec.md");
|
|
766
|
+
const planPath = join8(taskPath, "plan.md");
|
|
767
|
+
if (await getFsEntryKind(specPath) !== "file") {
|
|
768
|
+
throw new Error(`Missing spec.md for fast-track task ${startedTask.workspaceId}.`);
|
|
769
|
+
}
|
|
770
|
+
if (await getFsEntryKind(planPath) !== "file") {
|
|
771
|
+
throw new Error(`Missing plan.md for fast-track task ${startedTask.workspaceId}.`);
|
|
772
|
+
}
|
|
773
|
+
await writeFile5(specPath, renderMinimalSpec(input.type, input.slug), "utf8");
|
|
774
|
+
await writeFile5(planPath, renderMinimalPlan(input.slug), "utf8");
|
|
775
|
+
return {
|
|
776
|
+
failed: [],
|
|
777
|
+
nextStatus: "PENDING_SPEC_APPROVAL",
|
|
778
|
+
path: startedTask.workspacePath,
|
|
779
|
+
planCreated: true,
|
|
780
|
+
specCreated: true,
|
|
781
|
+
succeeded: [{ note: "created minimal spec and plan", taskId: startedTask.workspaceId }],
|
|
782
|
+
taskId: startedTask.workspaceId
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function buildFailureRows(taskId, failedRows, fallbackNote) {
|
|
786
|
+
if (failedRows.length === 0) {
|
|
787
|
+
if (fallbackNote === "") {
|
|
788
|
+
return [];
|
|
25
789
|
}
|
|
26
|
-
return
|
|
27
|
-
} catch {
|
|
28
|
-
return "missing";
|
|
790
|
+
return [{ note: fallbackNote, taskId }];
|
|
29
791
|
}
|
|
792
|
+
return failedRows.map((row) => ({ note: row.note, taskId: row.taskId }));
|
|
30
793
|
}
|
|
31
|
-
|
|
32
|
-
|
|
794
|
+
function buildSuccessRows(succeededRows) {
|
|
795
|
+
return succeededRows.map((row) => ({
|
|
796
|
+
note: `${row.note} (${String(row.steps)} steps)`,
|
|
797
|
+
taskId: row.taskId
|
|
798
|
+
}));
|
|
33
799
|
}
|
|
34
|
-
async function
|
|
35
|
-
await
|
|
800
|
+
async function approveFastTrackTask(target, projectRoot) {
|
|
801
|
+
await approveSpecs(projectRoot, [toSpecApprovalTarget(target)], createSpecApprovedAt());
|
|
802
|
+
const planResult = await approvePlans(
|
|
803
|
+
projectRoot,
|
|
804
|
+
[toPlanApprovalTarget(target)],
|
|
805
|
+
createPlanApprovedAt()
|
|
806
|
+
);
|
|
807
|
+
if (planResult.succeeded.length === 0) {
|
|
808
|
+
return {
|
|
809
|
+
approved: false,
|
|
810
|
+
failed: buildFailureRows(target.id, planResult.failed, "plan approval failed"),
|
|
811
|
+
nextStatus: "SPEC_APPROVED",
|
|
812
|
+
succeeded: [{ note: "approved minimal spec", taskId: target.id }],
|
|
813
|
+
taskId: target.id
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
approved: true,
|
|
818
|
+
failed: buildFailureRows(target.id, planResult.failed, ""),
|
|
819
|
+
nextStatus: "IN_PROGRESS",
|
|
820
|
+
succeeded: [
|
|
821
|
+
{ note: "approved minimal spec", taskId: target.id },
|
|
822
|
+
...buildSuccessRows(planResult.succeeded)
|
|
823
|
+
],
|
|
824
|
+
taskId: target.id
|
|
825
|
+
};
|
|
36
826
|
}
|
|
37
|
-
|
|
38
|
-
|
|
827
|
+
|
|
828
|
+
// src/commands/fast-track.ts
|
|
829
|
+
function formatFailures(rows) {
|
|
830
|
+
return rows.map((row) => `- ${row.taskId}: ${row.note}`);
|
|
831
|
+
}
|
|
832
|
+
async function runFastTrackCommand(input, projectRoot) {
|
|
833
|
+
try {
|
|
834
|
+
const createdTask = await createFastTrackTask(input, projectRoot);
|
|
835
|
+
const lines = [
|
|
836
|
+
"fast-track task created",
|
|
837
|
+
`\uACBD\uB85C: ${createdTask.path}/`,
|
|
838
|
+
"minimal spec: created",
|
|
839
|
+
"minimal plan: created"
|
|
840
|
+
];
|
|
841
|
+
if (!isInteractiveApprovalAvailable()) {
|
|
842
|
+
lines.push(
|
|
843
|
+
"\uC0C1\uD0DC: PENDING_SPEC_APPROVAL",
|
|
844
|
+
"\uB2E4\uC74C \uB2E8\uACC4: `sduck spec approve <slug>` \uD6C4 `sduck plan approve <slug>`\uB97C \uC2E4\uD589\uD558\uC138\uC694."
|
|
845
|
+
);
|
|
846
|
+
return {
|
|
847
|
+
exitCode: 0,
|
|
848
|
+
stderr: "",
|
|
849
|
+
stdout: lines.join("\n")
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const shouldApprove = await confirm({
|
|
853
|
+
default: true,
|
|
854
|
+
message: "Approve the minimal spec and minimal plan now?"
|
|
855
|
+
});
|
|
856
|
+
if (!shouldApprove) {
|
|
857
|
+
lines.push(
|
|
858
|
+
"\uC0C1\uD0DC: PENDING_SPEC_APPROVAL",
|
|
859
|
+
"\uB2E4\uC74C \uB2E8\uACC4: `sduck spec approve <slug>` \uD6C4 `sduck plan approve <slug>`\uB97C \uC2E4\uD589\uD558\uC138\uC694."
|
|
860
|
+
);
|
|
861
|
+
return {
|
|
862
|
+
exitCode: 0,
|
|
863
|
+
stderr: "",
|
|
864
|
+
stdout: lines.join("\n")
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const approvalResult = await approveFastTrackTask(
|
|
868
|
+
{
|
|
869
|
+
id: createdTask.taskId,
|
|
870
|
+
path: createdTask.path,
|
|
871
|
+
slug: input.slug,
|
|
872
|
+
status: "PENDING_SPEC_APPROVAL"
|
|
873
|
+
},
|
|
874
|
+
projectRoot
|
|
875
|
+
);
|
|
876
|
+
lines.push(`\uC0C1\uD0DC: ${approvalResult.nextStatus}`);
|
|
877
|
+
if (approvalResult.failed.length > 0) {
|
|
878
|
+
lines.push("\uC2B9\uC778 \uACB0\uACFC:", ...formatFailures(approvalResult.failed));
|
|
879
|
+
}
|
|
880
|
+
if (approvalResult.approved) {
|
|
881
|
+
lines.push("fast-track \uC2B9\uC778 \uC644\uB8CC \u2192 \uBC14\uB85C \uC791\uC5C5\uC744 \uC2DC\uC791\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
|
|
882
|
+
} else {
|
|
883
|
+
lines.push("\uC77C\uBD80 \uC2B9\uC778 \uB2E8\uACC4\uAC00 \uC644\uB8CC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uC77C\uBC18 \uC2B9\uC778 \uBA85\uB839\uC73C\uB85C \uC774\uC5B4\uC11C \uC9C4\uD589\uD558\uC138\uC694.");
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
exitCode: approvalResult.failed.length > 0 ? 1 : 0,
|
|
887
|
+
stderr: "",
|
|
888
|
+
stdout: lines.join("\n")
|
|
889
|
+
};
|
|
890
|
+
} catch (error) {
|
|
891
|
+
return {
|
|
892
|
+
exitCode: 1,
|
|
893
|
+
stderr: error instanceof Error ? error.message : "Unknown fast-track failure.",
|
|
894
|
+
stdout: ""
|
|
895
|
+
};
|
|
896
|
+
}
|
|
39
897
|
}
|
|
40
898
|
|
|
899
|
+
// src/commands/init.ts
|
|
900
|
+
import { checkbox } from "@inquirer/prompts";
|
|
901
|
+
|
|
41
902
|
// src/core/agent-rules.ts
|
|
903
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
904
|
+
import { dirname as dirname2, join as join9 } from "path";
|
|
905
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
42
906
|
var SDD_RULES_BEGIN = "<!-- sduck:begin -->";
|
|
43
907
|
var SDD_RULES_END = "<!-- sduck:end -->";
|
|
44
908
|
var SUPPORTED_AGENTS = [
|
|
@@ -56,12 +920,12 @@ var AGENT_RULE_TARGETS = [
|
|
|
56
920
|
{ agentId: "gemini-cli", outputPath: "GEMINI.md", kind: "root-file" },
|
|
57
921
|
{
|
|
58
922
|
agentId: "cursor",
|
|
59
|
-
outputPath:
|
|
923
|
+
outputPath: join9(".cursor", "rules", "sduck-core.mdc"),
|
|
60
924
|
kind: "managed-file"
|
|
61
925
|
},
|
|
62
926
|
{
|
|
63
927
|
agentId: "antigravity",
|
|
64
|
-
outputPath:
|
|
928
|
+
outputPath: join9(".agents", "rules", "sduck-core.md"),
|
|
65
929
|
kind: "managed-file"
|
|
66
930
|
}
|
|
67
931
|
];
|
|
@@ -120,10 +984,10 @@ function renderManagedBlock(lines) {
|
|
|
120
984
|
return [SDD_RULES_BEGIN, ...lines, SDD_RULES_END].join("\n");
|
|
121
985
|
}
|
|
122
986
|
async function getAgentRulesAssetRoot() {
|
|
123
|
-
const currentDirectoryPath =
|
|
987
|
+
const currentDirectoryPath = dirname2(fileURLToPath2(import.meta.url));
|
|
124
988
|
const candidatePaths = [
|
|
125
|
-
|
|
126
|
-
|
|
989
|
+
join9(currentDirectoryPath, "..", "..", ".sduck", "sduck-assets", "agent-rules"),
|
|
990
|
+
join9(currentDirectoryPath, "..", ".sduck", "sduck-assets", "agent-rules")
|
|
127
991
|
];
|
|
128
992
|
for (const candidatePath of candidatePaths) {
|
|
129
993
|
if (await getFsEntryKind(candidatePath) === "directory") {
|
|
@@ -133,7 +997,7 @@ async function getAgentRulesAssetRoot() {
|
|
|
133
997
|
throw new Error("Unable to locate bundled sduck agent rule assets.");
|
|
134
998
|
}
|
|
135
999
|
async function readAssetFile(assetRoot, fileName) {
|
|
136
|
-
return await
|
|
1000
|
+
return await readFile6(join9(assetRoot, fileName), "utf8");
|
|
137
1001
|
}
|
|
138
1002
|
function buildRootFileLines(agentIds, agentSpecificContent) {
|
|
139
1003
|
const labels = SUPPORTED_AGENTS.filter((agent) => agentIds.includes(agent.id)).map(
|
|
@@ -166,110 +1030,66 @@ ${coreContent.trim()}
|
|
|
166
1030
|
const templateFileName = AGENT_TEMPLATE_FILES[agentId];
|
|
167
1031
|
specificSections.push((await readAssetFile(assetRoot, templateFileName)).trim());
|
|
168
1032
|
}
|
|
169
|
-
const lines = buildRootFileLines(relatedAgents, [...specificSections, coreContent.trim()]);
|
|
170
|
-
return `${renderManagedBlock(lines)}
|
|
171
|
-
`;
|
|
172
|
-
}
|
|
173
|
-
function planAgentRuleActions(mode, targets, existingEntries, existingContents) {
|
|
174
|
-
return targets.map((target) => {
|
|
175
|
-
const currentKind = existingEntries.get(target.outputPath) ?? "missing";
|
|
176
|
-
if (currentKind === "missing") {
|
|
177
|
-
return { ...target, mergeMode: "create", currentKind };
|
|
178
|
-
}
|
|
179
|
-
if (currentKind !== "file") {
|
|
180
|
-
return { ...target, mergeMode: "overwrite", currentKind };
|
|
181
|
-
}
|
|
182
|
-
if (target.kind === "managed-file") {
|
|
183
|
-
return { ...target, mergeMode: mode === "force" ? "overwrite" : "keep", currentKind };
|
|
184
|
-
}
|
|
185
|
-
const content = existingContents.get(target.outputPath) ?? "";
|
|
186
|
-
if (mode === "safe") {
|
|
187
|
-
return { ...target, mergeMode: hasManagedBlock(content) ? "keep" : "prepend", currentKind };
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
...target,
|
|
191
|
-
mergeMode: hasManagedBlock(content) ? "replace-block" : "prepend",
|
|
192
|
-
currentKind
|
|
193
|
-
};
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// src/core/init.ts
|
|
198
|
-
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
199
|
-
import { dirname as dirname3, join as join3 } from "path";
|
|
200
|
-
|
|
201
|
-
// src/core/assets.ts
|
|
202
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
203
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
204
|
-
var SUPPORTED_TASK_TYPES = [
|
|
205
|
-
"build",
|
|
206
|
-
"feature",
|
|
207
|
-
"fix",
|
|
208
|
-
"refactor",
|
|
209
|
-
"chore"
|
|
210
|
-
];
|
|
211
|
-
var EVAL_ASSET_RELATIVE_PATHS = {
|
|
212
|
-
plan: join2("eval", "plan.yml"),
|
|
213
|
-
spec: join2("eval", "spec.yml")
|
|
214
|
-
};
|
|
215
|
-
var SPEC_TEMPLATE_RELATIVE_PATHS = {
|
|
216
|
-
build: join2("types", "build.md"),
|
|
217
|
-
feature: join2("types", "feature.md"),
|
|
218
|
-
fix: join2("types", "fix.md"),
|
|
219
|
-
refactor: join2("types", "refactor.md"),
|
|
220
|
-
chore: join2("types", "chore.md")
|
|
221
|
-
};
|
|
222
|
-
var INIT_ASSET_RELATIVE_PATHS = [
|
|
223
|
-
EVAL_ASSET_RELATIVE_PATHS.spec,
|
|
224
|
-
EVAL_ASSET_RELATIVE_PATHS.plan,
|
|
225
|
-
...Object.values(SPEC_TEMPLATE_RELATIVE_PATHS)
|
|
226
|
-
];
|
|
227
|
-
async function getBundledAssetsRoot() {
|
|
228
|
-
const currentDirectoryPath = dirname2(fileURLToPath2(import.meta.url));
|
|
229
|
-
const candidatePaths = [
|
|
230
|
-
join2(currentDirectoryPath, "..", "..", "sduck-assets"),
|
|
231
|
-
join2(currentDirectoryPath, "..", "sduck-assets")
|
|
232
|
-
];
|
|
233
|
-
for (const candidatePath of candidatePaths) {
|
|
234
|
-
if (await getFsEntryKind(candidatePath) === "directory") {
|
|
235
|
-
return candidatePath;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
throw new Error("Unable to locate bundled sduck-assets directory.");
|
|
239
|
-
}
|
|
240
|
-
function isSupportedTaskType(value) {
|
|
241
|
-
return SUPPORTED_TASK_TYPES.includes(value);
|
|
1033
|
+
const lines = buildRootFileLines(relatedAgents, [...specificSections, coreContent.trim()]);
|
|
1034
|
+
return `${renderManagedBlock(lines)}
|
|
1035
|
+
`;
|
|
242
1036
|
}
|
|
243
|
-
function
|
|
244
|
-
return
|
|
1037
|
+
function planAgentRuleActions(mode, targets, existingEntries, existingContents) {
|
|
1038
|
+
return targets.map((target) => {
|
|
1039
|
+
const currentKind = existingEntries.get(target.outputPath) ?? "missing";
|
|
1040
|
+
if (currentKind === "missing") {
|
|
1041
|
+
return { ...target, mergeMode: "create", currentKind };
|
|
1042
|
+
}
|
|
1043
|
+
if (currentKind !== "file") {
|
|
1044
|
+
return { ...target, mergeMode: "overwrite", currentKind };
|
|
1045
|
+
}
|
|
1046
|
+
if (target.kind === "managed-file") {
|
|
1047
|
+
return { ...target, mergeMode: mode === "force" ? "overwrite" : "keep", currentKind };
|
|
1048
|
+
}
|
|
1049
|
+
const content = existingContents.get(target.outputPath) ?? "";
|
|
1050
|
+
if (mode === "safe") {
|
|
1051
|
+
return { ...target, mergeMode: hasManagedBlock(content) ? "keep" : "prepend", currentKind };
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
...target,
|
|
1055
|
+
mergeMode: hasManagedBlock(content) ? "replace-block" : "prepend",
|
|
1056
|
+
currentKind
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
245
1059
|
}
|
|
246
1060
|
|
|
247
1061
|
// src/core/init.ts
|
|
1062
|
+
import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
1063
|
+
import { dirname as dirname3, join as join10 } from "path";
|
|
248
1064
|
var ASSET_TEMPLATE_DEFINITIONS = [
|
|
249
1065
|
{
|
|
250
1066
|
key: "eval-spec",
|
|
251
|
-
relativePath:
|
|
1067
|
+
relativePath: getProjectRelativeSduckAssetPath(EVAL_ASSET_RELATIVE_PATHS.spec)
|
|
252
1068
|
},
|
|
253
1069
|
{
|
|
254
1070
|
key: "eval-plan",
|
|
255
|
-
relativePath:
|
|
1071
|
+
relativePath: getProjectRelativeSduckAssetPath(EVAL_ASSET_RELATIVE_PATHS.plan)
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
key: "eval-task",
|
|
1075
|
+
relativePath: getProjectRelativeSduckAssetPath(EVAL_ASSET_RELATIVE_PATHS.task)
|
|
256
1076
|
},
|
|
257
1077
|
{
|
|
258
1078
|
key: "type-build",
|
|
259
|
-
relativePath:
|
|
1079
|
+
relativePath: getProjectRelativeSduckAssetPath("types", "build.md")
|
|
260
1080
|
},
|
|
261
1081
|
{
|
|
262
1082
|
key: "type-feature",
|
|
263
|
-
relativePath:
|
|
1083
|
+
relativePath: getProjectRelativeSduckAssetPath("types", "feature.md")
|
|
264
1084
|
},
|
|
265
|
-
{ key: "type-fix", relativePath:
|
|
1085
|
+
{ key: "type-fix", relativePath: getProjectRelativeSduckAssetPath("types", "fix.md") },
|
|
266
1086
|
{
|
|
267
1087
|
key: "type-refactor",
|
|
268
|
-
relativePath:
|
|
1088
|
+
relativePath: getProjectRelativeSduckAssetPath("types", "refactor.md")
|
|
269
1089
|
},
|
|
270
1090
|
{
|
|
271
1091
|
key: "type-chore",
|
|
272
|
-
relativePath:
|
|
1092
|
+
relativePath: getProjectRelativeSduckAssetPath("types", "chore.md")
|
|
273
1093
|
}
|
|
274
1094
|
];
|
|
275
1095
|
var ASSET_TEMPLATE_MAP = Object.fromEntries(
|
|
@@ -353,7 +1173,7 @@ async function collectExistingEntries(projectRoot) {
|
|
|
353
1173
|
for (const definition of ASSET_TEMPLATE_DEFINITIONS) {
|
|
354
1174
|
existingEntries.set(
|
|
355
1175
|
definition.relativePath,
|
|
356
|
-
await getFsEntryKind(
|
|
1176
|
+
await getFsEntryKind(join10(projectRoot, definition.relativePath))
|
|
357
1177
|
);
|
|
358
1178
|
}
|
|
359
1179
|
return existingEntries;
|
|
@@ -361,9 +1181,9 @@ async function collectExistingEntries(projectRoot) {
|
|
|
361
1181
|
async function collectExistingFileContents(projectRoot, targets) {
|
|
362
1182
|
const contents = /* @__PURE__ */ new Map();
|
|
363
1183
|
for (const target of targets) {
|
|
364
|
-
const targetPath =
|
|
1184
|
+
const targetPath = join10(projectRoot, target.outputPath);
|
|
365
1185
|
if (await getFsEntryKind(targetPath) === "file") {
|
|
366
|
-
contents.set(target.outputPath, await
|
|
1186
|
+
contents.set(target.outputPath, await readFile7(targetPath, "utf8"));
|
|
367
1187
|
}
|
|
368
1188
|
}
|
|
369
1189
|
return contents;
|
|
@@ -383,8 +1203,9 @@ async function initProject(options, projectRoot) {
|
|
|
383
1203
|
const resolvedOptions = resolveInitOptions(options);
|
|
384
1204
|
const { mode } = resolvedOptions;
|
|
385
1205
|
const assetSourceRoot = await getBundledAssetsRoot();
|
|
386
|
-
const
|
|
387
|
-
const
|
|
1206
|
+
const sduckHomeRoot = getProjectSduckHomePath(projectRoot);
|
|
1207
|
+
const assetsRoot = getProjectSduckAssetsPath(projectRoot);
|
|
1208
|
+
const workspaceRoot = getProjectSduckWorkspacePath(projectRoot);
|
|
388
1209
|
const summary = {
|
|
389
1210
|
created: [],
|
|
390
1211
|
prepended: [],
|
|
@@ -394,12 +1215,18 @@ async function initProject(options, projectRoot) {
|
|
|
394
1215
|
errors: [],
|
|
395
1216
|
rows: []
|
|
396
1217
|
};
|
|
1218
|
+
const sduckHomeStatus = await ensureRootDirectory(sduckHomeRoot, "asset-root-conflict");
|
|
1219
|
+
summary[sduckHomeStatus].push(`${PROJECT_SDUCK_HOME_RELATIVE_PATH}/`);
|
|
1220
|
+
summary.rows.push({ path: `${PROJECT_SDUCK_HOME_RELATIVE_PATH}/`, status: sduckHomeStatus });
|
|
397
1221
|
const assetsRootStatus = await ensureRootDirectory(assetsRoot, "asset-root-conflict");
|
|
398
|
-
summary[assetsRootStatus].push(
|
|
399
|
-
summary.rows.push({ path:
|
|
1222
|
+
summary[assetsRootStatus].push(`${PROJECT_SDUCK_ASSETS_RELATIVE_PATH}/`);
|
|
1223
|
+
summary.rows.push({ path: `${PROJECT_SDUCK_ASSETS_RELATIVE_PATH}/`, status: assetsRootStatus });
|
|
400
1224
|
const workspaceRootStatus = await ensureRootDirectory(workspaceRoot, "workspace-root-conflict");
|
|
401
|
-
summary[workspaceRootStatus].push(
|
|
402
|
-
summary.rows.push({
|
|
1225
|
+
summary[workspaceRootStatus].push(`${PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH}/`);
|
|
1226
|
+
summary.rows.push({
|
|
1227
|
+
path: `${PROJECT_SDUCK_WORKSPACE_RELATIVE_PATH}/`,
|
|
1228
|
+
status: workspaceRootStatus
|
|
1229
|
+
});
|
|
403
1230
|
const actions = planInitActions(mode, await collectExistingEntries(projectRoot));
|
|
404
1231
|
const actionSummary = summarizeInitActions(actions);
|
|
405
1232
|
summary.created.push(...actionSummary.created);
|
|
@@ -419,13 +1246,10 @@ async function initProject(options, projectRoot) {
|
|
|
419
1246
|
continue;
|
|
420
1247
|
}
|
|
421
1248
|
const definition = ASSET_TEMPLATE_MAP[action.key];
|
|
422
|
-
const sourcePath =
|
|
423
|
-
|
|
424
|
-
definition.relativePath.replace(/^sduck-assets[\\/]/, "")
|
|
425
|
-
);
|
|
426
|
-
const targetPath = join3(projectRoot, definition.relativePath);
|
|
1249
|
+
const sourcePath = join10(assetSourceRoot, toBundledAssetRelativePath(definition.relativePath));
|
|
1250
|
+
const targetPath = join10(projectRoot, definition.relativePath);
|
|
427
1251
|
await ensureReadableFile(sourcePath);
|
|
428
|
-
await
|
|
1252
|
+
await mkdir3(dirname3(targetPath), { recursive: true });
|
|
429
1253
|
await copyFileIntoPlace(sourcePath, targetPath);
|
|
430
1254
|
}
|
|
431
1255
|
const agentTargets = listAgentRuleTargets(resolvedOptions.agents);
|
|
@@ -433,7 +1257,7 @@ async function initProject(options, projectRoot) {
|
|
|
433
1257
|
for (const target of agentTargets) {
|
|
434
1258
|
agentEntryKinds.set(
|
|
435
1259
|
target.outputPath,
|
|
436
|
-
await getFsEntryKind(
|
|
1260
|
+
await getFsEntryKind(join10(projectRoot, target.outputPath))
|
|
437
1261
|
);
|
|
438
1262
|
}
|
|
439
1263
|
const existingContents = await collectExistingFileContents(projectRoot, agentTargets);
|
|
@@ -454,11 +1278,11 @@ async function initProject(options, projectRoot) {
|
|
|
454
1278
|
}
|
|
455
1279
|
async function applyAgentRuleActions(projectRoot, actions, existingContents, summary, selectedAgents) {
|
|
456
1280
|
for (const action of actions) {
|
|
457
|
-
const targetPath =
|
|
1281
|
+
const targetPath = join10(projectRoot, action.outputPath);
|
|
458
1282
|
const content = await renderAgentRuleContent(action, selectedAgents);
|
|
459
1283
|
if (action.mergeMode === "create") {
|
|
460
|
-
await
|
|
461
|
-
await
|
|
1284
|
+
await mkdir3(dirname3(targetPath), { recursive: true });
|
|
1285
|
+
await writeFile6(targetPath, content, "utf8");
|
|
462
1286
|
summary.created.push(action.outputPath);
|
|
463
1287
|
summary.rows.push({ path: action.outputPath, status: "created" });
|
|
464
1288
|
continue;
|
|
@@ -469,22 +1293,22 @@ async function applyAgentRuleActions(projectRoot, actions, existingContents, sum
|
|
|
469
1293
|
summary.warnings.push(`Kept existing rule file: ${action.outputPath}`);
|
|
470
1294
|
continue;
|
|
471
1295
|
}
|
|
472
|
-
await
|
|
1296
|
+
await mkdir3(dirname3(targetPath), { recursive: true });
|
|
473
1297
|
if (action.mergeMode === "prepend") {
|
|
474
1298
|
const currentContent = existingContents.get(action.outputPath) ?? "";
|
|
475
|
-
await
|
|
1299
|
+
await writeFile6(targetPath, prependManagedBlock(currentContent, content.trimEnd()), "utf8");
|
|
476
1300
|
summary.prepended.push(action.outputPath);
|
|
477
1301
|
summary.rows.push({ path: action.outputPath, status: "prepended" });
|
|
478
1302
|
continue;
|
|
479
1303
|
}
|
|
480
1304
|
if (action.mergeMode === "replace-block") {
|
|
481
1305
|
const currentContent = existingContents.get(action.outputPath) ?? "";
|
|
482
|
-
await
|
|
1306
|
+
await writeFile6(targetPath, replaceManagedBlock(currentContent, content.trimEnd()), "utf8");
|
|
483
1307
|
summary.overwritten.push(action.outputPath);
|
|
484
1308
|
summary.rows.push({ path: action.outputPath, status: "overwritten" });
|
|
485
1309
|
continue;
|
|
486
1310
|
}
|
|
487
|
-
await
|
|
1311
|
+
await writeFile6(targetPath, content, "utf8");
|
|
488
1312
|
summary.overwritten.push(action.outputPath);
|
|
489
1313
|
summary.rows.push({ path: action.outputPath, status: "overwritten" });
|
|
490
1314
|
}
|
|
@@ -493,274 +1317,92 @@ async function applyAgentRuleActions(projectRoot, actions, existingContents, sum
|
|
|
493
1317
|
"Run `sduck init --force` to refresh managed rule content for selected agents."
|
|
494
1318
|
);
|
|
495
1319
|
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// src/commands/init.ts
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
function
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
return normalizeSelectedAgents(
|
|
538
|
-
await checkbox({
|
|
539
|
-
message: "Select AI agents to generate repository rule files for",
|
|
540
|
-
choices: SUPPORTED_AGENTS.map((agent) => ({
|
|
541
|
-
name: agent.label,
|
|
542
|
-
value: agent.id
|
|
543
|
-
}))
|
|
544
|
-
})
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
async function runInitCommand(options, projectRoot) {
|
|
548
|
-
try {
|
|
549
|
-
const resolvedOptions = {
|
|
550
|
-
force: options.force,
|
|
551
|
-
agents: await resolveSelectedAgents(options)
|
|
552
|
-
};
|
|
553
|
-
const result = await initProject(resolvedOptions, projectRoot);
|
|
554
|
-
return {
|
|
555
|
-
exitCode: 0,
|
|
556
|
-
stderr: "",
|
|
557
|
-
stdout: formatResult(result)
|
|
558
|
-
};
|
|
559
|
-
} catch (error) {
|
|
560
|
-
const message = error instanceof Error ? error.message : "Unknown init failure.";
|
|
561
|
-
return {
|
|
562
|
-
exitCode: 1,
|
|
563
|
-
stderr: message,
|
|
564
|
-
stdout: ""
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// src/commands/plan-approve.ts
|
|
570
|
-
import { checkbox as checkbox2 } from "@inquirer/prompts";
|
|
571
|
-
|
|
572
|
-
// src/core/plan-approve.ts
|
|
573
|
-
import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
574
|
-
import { join as join5 } from "path";
|
|
575
|
-
|
|
576
|
-
// src/core/workspace.ts
|
|
577
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
578
|
-
import { join as join4 } from "path";
|
|
579
|
-
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["IN_PROGRESS", "PENDING_SPEC_APPROVAL", "PENDING_PLAN_APPROVAL"]);
|
|
580
|
-
function parseMetaText(content) {
|
|
581
|
-
const createdAtMatch = /^created_at:\s+(.+)$/m.exec(content);
|
|
582
|
-
const idMatch = /^id:\s+(.+)$/m.exec(content);
|
|
583
|
-
const slugMatch = /^slug:\s+(.+)$/m.exec(content);
|
|
584
|
-
const statusMatch = /^status:\s+(.+)$/m.exec(content);
|
|
585
|
-
const parsedMeta = {};
|
|
586
|
-
if (createdAtMatch?.[1] !== void 0) {
|
|
587
|
-
parsedMeta.createdAt = createdAtMatch[1].trim();
|
|
588
|
-
}
|
|
589
|
-
if (idMatch?.[1] !== void 0) {
|
|
590
|
-
parsedMeta.id = idMatch[1].trim();
|
|
591
|
-
}
|
|
592
|
-
if (slugMatch?.[1] !== void 0) {
|
|
593
|
-
parsedMeta.slug = slugMatch[1].trim();
|
|
594
|
-
}
|
|
595
|
-
if (statusMatch?.[1] !== void 0) {
|
|
596
|
-
parsedMeta.status = statusMatch[1].trim();
|
|
597
|
-
}
|
|
598
|
-
return parsedMeta;
|
|
599
|
-
}
|
|
600
|
-
function sortTasksByRecency(tasks) {
|
|
601
|
-
return [...tasks].sort((left, right) => {
|
|
602
|
-
const leftValue = left.createdAt ?? "";
|
|
603
|
-
const rightValue = right.createdAt ?? "";
|
|
604
|
-
return rightValue.localeCompare(leftValue);
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
async function listWorkspaceTasks(projectRoot) {
|
|
608
|
-
const workspaceRoot = join4(projectRoot, "sduck-workspace");
|
|
609
|
-
if (await getFsEntryKind(workspaceRoot) !== "directory") {
|
|
610
|
-
return [];
|
|
611
|
-
}
|
|
612
|
-
const { readdir } = await import("fs/promises");
|
|
613
|
-
const entries = await readdir(workspaceRoot, { withFileTypes: true });
|
|
614
|
-
const tasks = [];
|
|
615
|
-
for (const entry of entries) {
|
|
616
|
-
if (!entry.isDirectory()) {
|
|
617
|
-
continue;
|
|
618
|
-
}
|
|
619
|
-
const relativePath = join4("sduck-workspace", entry.name);
|
|
620
|
-
const metaPath = join4(projectRoot, relativePath, "meta.yml");
|
|
621
|
-
if (await getFsEntryKind(metaPath) !== "file") {
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
const parsedMeta = parseMetaText(await readFile3(metaPath, "utf8"));
|
|
625
|
-
if (parsedMeta.id !== void 0 && parsedMeta.status !== void 0) {
|
|
626
|
-
const task = {
|
|
627
|
-
id: parsedMeta.id,
|
|
628
|
-
path: relativePath,
|
|
629
|
-
status: parsedMeta.status
|
|
630
|
-
};
|
|
631
|
-
if (parsedMeta.createdAt !== void 0) {
|
|
632
|
-
task.createdAt = parsedMeta.createdAt;
|
|
633
|
-
}
|
|
634
|
-
if (parsedMeta.slug !== void 0) {
|
|
635
|
-
task.slug = parsedMeta.slug;
|
|
636
|
-
}
|
|
637
|
-
tasks.push(task);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return sortTasksByRecency(tasks);
|
|
641
|
-
}
|
|
642
|
-
async function findActiveTask(projectRoot) {
|
|
643
|
-
const tasks = await listWorkspaceTasks(projectRoot);
|
|
644
|
-
for (const task of tasks) {
|
|
645
|
-
if (ACTIVE_STATUSES.has(task.status)) {
|
|
646
|
-
return {
|
|
647
|
-
id: task.id,
|
|
648
|
-
path: task.path,
|
|
649
|
-
status: task.status
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// src/utils/utc-date.ts
|
|
657
|
-
function pad2(value) {
|
|
658
|
-
return String(value).padStart(2, "0");
|
|
659
|
-
}
|
|
660
|
-
function formatUtcDate(date) {
|
|
661
|
-
const year = String(date.getUTCFullYear());
|
|
662
|
-
const month = pad2(date.getUTCMonth() + 1);
|
|
663
|
-
const day = pad2(date.getUTCDate());
|
|
664
|
-
return `${year}-${month}-${day}`;
|
|
665
|
-
}
|
|
666
|
-
function formatUtcTimestamp(date) {
|
|
667
|
-
const year = String(date.getUTCFullYear());
|
|
668
|
-
const month = pad2(date.getUTCMonth() + 1);
|
|
669
|
-
const day = pad2(date.getUTCDate());
|
|
670
|
-
const hour = pad2(date.getUTCHours());
|
|
671
|
-
const minute = pad2(date.getUTCMinutes());
|
|
672
|
-
const second = pad2(date.getUTCSeconds());
|
|
673
|
-
return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// src/core/plan-approve.ts
|
|
677
|
-
function filterPlanApprovalCandidates(tasks) {
|
|
678
|
-
return tasks.filter((task) => task.status === "SPEC_APPROVED");
|
|
679
|
-
}
|
|
680
|
-
function resolvePlanApprovalCandidates(tasks, target) {
|
|
681
|
-
const candidates = filterPlanApprovalCandidates(tasks);
|
|
682
|
-
if (target === void 0 || target.trim() === "") {
|
|
683
|
-
return candidates;
|
|
684
|
-
}
|
|
685
|
-
const trimmedTarget = target.trim();
|
|
686
|
-
return candidates.filter(
|
|
687
|
-
(task) => task.id === trimmedTarget || task.slug === trimmedTarget || task.id.endsWith(trimmedTarget)
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
function countPlanSteps(planContent) {
|
|
691
|
-
const matches = planContent.match(/^## Step \d+\. .+$/gm);
|
|
692
|
-
return matches?.length ?? 0;
|
|
693
|
-
}
|
|
694
|
-
function updatePlanApprovalBlock(metaContent, approvedAt, totalSteps) {
|
|
695
|
-
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: IN_PROGRESS");
|
|
696
|
-
const withPlan = withStatus.replace(
|
|
697
|
-
/plan:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
698
|
-
`plan:
|
|
699
|
-
approved: true
|
|
700
|
-
approved_at: ${approvedAt}`
|
|
701
|
-
);
|
|
702
|
-
return withPlan.replace(
|
|
703
|
-
/steps:\n {2}total:\s+null\n {2}completed:\s+\[\]/m,
|
|
704
|
-
`steps:
|
|
705
|
-
total: ${String(totalSteps)}
|
|
706
|
-
completed: []`
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
async function approvePlans(projectRoot, tasks, approvedAt) {
|
|
710
|
-
const succeeded = [];
|
|
711
|
-
const failed = [];
|
|
712
|
-
for (const task of tasks) {
|
|
713
|
-
if (task.status !== "SPEC_APPROVED") {
|
|
714
|
-
failed.push({
|
|
715
|
-
note: `task is not awaiting plan approval (${task.status})`,
|
|
716
|
-
taskId: task.id
|
|
717
|
-
});
|
|
718
|
-
continue;
|
|
719
|
-
}
|
|
720
|
-
const metaPath = join5(projectRoot, task.path, "meta.yml");
|
|
721
|
-
const planPath = join5(projectRoot, task.path, "plan.md");
|
|
722
|
-
if (await getFsEntryKind(metaPath) !== "file") {
|
|
723
|
-
failed.push({ note: "missing meta.yml", taskId: task.id });
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
if (await getFsEntryKind(planPath) !== "file") {
|
|
727
|
-
failed.push({ note: "missing plan.md", taskId: task.id });
|
|
728
|
-
continue;
|
|
729
|
-
}
|
|
730
|
-
const planContent = await readFile4(planPath, "utf8");
|
|
731
|
-
const totalSteps = countPlanSteps(planContent);
|
|
732
|
-
if (totalSteps === 0) {
|
|
733
|
-
failed.push({ note: "missing valid Step headers", taskId: task.id });
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
const updatedMeta = updatePlanApprovalBlock(
|
|
737
|
-
await readFile4(metaPath, "utf8"),
|
|
738
|
-
approvedAt,
|
|
739
|
-
totalSteps
|
|
740
|
-
);
|
|
741
|
-
await writeFile2(metaPath, updatedMeta, "utf8");
|
|
742
|
-
succeeded.push({ note: "moved to IN_PROGRESS", steps: totalSteps, taskId: task.id });
|
|
743
|
-
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// src/commands/init.ts
|
|
1323
|
+
var AGENT_PROMPT_MESSAGE = "Select AI agents to generate repository rule files for";
|
|
1324
|
+
var AGENT_PROMPT_INSTRUCTIONS = "Use space to toggle agents, arrow keys to move, and enter to submit.";
|
|
1325
|
+
var AGENT_PROMPT_REQUIRED_MESSAGE = "Select at least one agent. Use space to toggle and enter to submit.";
|
|
1326
|
+
function padCell2(value, width) {
|
|
1327
|
+
return value.padEnd(width, " ");
|
|
1328
|
+
}
|
|
1329
|
+
function buildSummaryTable(rows) {
|
|
1330
|
+
const statusWidth = Math.max("Status".length, ...rows.map((row) => row.status.length));
|
|
1331
|
+
const pathWidth = Math.max("Path".length, ...rows.map((row) => row.path.length));
|
|
1332
|
+
const border = `+-${"-".repeat(statusWidth)}-+-${"-".repeat(pathWidth)}-+`;
|
|
1333
|
+
const header = `| ${padCell2("Status", statusWidth)} | ${padCell2("Path", pathWidth)} |`;
|
|
1334
|
+
const body = rows.map(
|
|
1335
|
+
(row) => `| ${padCell2(row.status, statusWidth)} | ${padCell2(row.path, pathWidth)} |`
|
|
1336
|
+
);
|
|
1337
|
+
return [border, header, border, ...body, border].join("\n");
|
|
1338
|
+
}
|
|
1339
|
+
function formatResult(result) {
|
|
1340
|
+
const lines = [
|
|
1341
|
+
result.didChange ? "sduck init completed." : "sduck init completed with no file changes."
|
|
1342
|
+
];
|
|
1343
|
+
if (result.agents.length > 0) {
|
|
1344
|
+
lines.push(`Selected agents: ${result.agents.join(", ")}`);
|
|
1345
|
+
}
|
|
1346
|
+
lines.push("", buildSummaryTable(result.summary.rows));
|
|
1347
|
+
if (result.summary.warnings.length > 0) {
|
|
1348
|
+
lines.push("", "Warnings:");
|
|
1349
|
+
lines.push(...result.summary.warnings.map((warning) => `- ${warning}`));
|
|
1350
|
+
}
|
|
1351
|
+
return lines.join("\n");
|
|
1352
|
+
}
|
|
1353
|
+
function normalizeSelectedAgents(agentIds) {
|
|
1354
|
+
const selectedAgentSet = new Set(agentIds);
|
|
1355
|
+
return SUPPORTED_AGENTS.map((agent) => agent.id).filter(
|
|
1356
|
+
(agentId) => selectedAgentSet.has(agentId)
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
function createAgentCheckboxConfig() {
|
|
744
1360
|
return {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1361
|
+
message: AGENT_PROMPT_MESSAGE,
|
|
1362
|
+
instructions: AGENT_PROMPT_INSTRUCTIONS,
|
|
1363
|
+
required: true,
|
|
1364
|
+
validate: (choices) => choices.length > 0 || AGENT_PROMPT_REQUIRED_MESSAGE,
|
|
1365
|
+
choices: SUPPORTED_AGENTS.map((agent) => ({
|
|
1366
|
+
name: agent.label,
|
|
1367
|
+
value: agent.id
|
|
1368
|
+
}))
|
|
749
1369
|
};
|
|
750
1370
|
}
|
|
751
|
-
async function
|
|
752
|
-
const
|
|
753
|
-
|
|
1371
|
+
async function resolveSelectedAgents(options) {
|
|
1372
|
+
const parsedAgents = parseAgentsOption(options.agents);
|
|
1373
|
+
if (parsedAgents.length > 0 || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1374
|
+
return normalizeSelectedAgents(parsedAgents);
|
|
1375
|
+
}
|
|
1376
|
+
return normalizeSelectedAgents(await checkbox(createAgentCheckboxConfig()));
|
|
754
1377
|
}
|
|
755
|
-
function
|
|
756
|
-
|
|
1378
|
+
async function runInitCommand(options, projectRoot) {
|
|
1379
|
+
try {
|
|
1380
|
+
const resolvedOptions = {
|
|
1381
|
+
force: options.force,
|
|
1382
|
+
agents: await resolveSelectedAgents(options)
|
|
1383
|
+
};
|
|
1384
|
+
const result = await initProject(resolvedOptions, projectRoot);
|
|
1385
|
+
return {
|
|
1386
|
+
exitCode: 0,
|
|
1387
|
+
stderr: "",
|
|
1388
|
+
stdout: formatResult(result)
|
|
1389
|
+
};
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
const message = error instanceof Error ? error.message : "Unknown init failure.";
|
|
1392
|
+
return {
|
|
1393
|
+
exitCode: 1,
|
|
1394
|
+
stderr: message,
|
|
1395
|
+
stdout: ""
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
757
1398
|
}
|
|
758
1399
|
|
|
759
1400
|
// src/commands/plan-approve.ts
|
|
760
|
-
|
|
1401
|
+
import { checkbox as checkbox2 } from "@inquirer/prompts";
|
|
1402
|
+
function padCell3(value, width) {
|
|
761
1403
|
return value.padEnd(width, " ");
|
|
762
1404
|
}
|
|
763
|
-
function
|
|
1405
|
+
function buildResultTable2(result) {
|
|
764
1406
|
const rows = [
|
|
765
1407
|
...result.succeeded.map((row) => ({
|
|
766
1408
|
note: row.note,
|
|
@@ -780,17 +1422,17 @@ function buildResultTable(result) {
|
|
|
780
1422
|
const stepsWidth = Math.max("Steps".length, ...rows.map((row) => row.steps.length));
|
|
781
1423
|
const noteWidth = Math.max("Note".length, ...rows.map((row) => row.note.length));
|
|
782
1424
|
const border = `+-${"-".repeat(resultWidth)}-+-${"-".repeat(taskWidth)}-+-${"-".repeat(stepsWidth)}-+-${"-".repeat(noteWidth)}-+`;
|
|
783
|
-
const header = `| ${
|
|
1425
|
+
const header = `| ${padCell3("Result", resultWidth)} | ${padCell3("Task", taskWidth)} | ${padCell3("Steps", stepsWidth)} | ${padCell3("Note", noteWidth)} |`;
|
|
784
1426
|
const body = rows.map(
|
|
785
|
-
(row) => `| ${
|
|
1427
|
+
(row) => `| ${padCell3(row.result, resultWidth)} | ${padCell3(row.task, taskWidth)} | ${padCell3(row.steps, stepsWidth)} | ${padCell3(row.note, noteWidth)} |`
|
|
786
1428
|
);
|
|
787
1429
|
return [border, header, border, ...body, border].join("\n");
|
|
788
1430
|
}
|
|
789
1431
|
function formatTaskLabel(task) {
|
|
790
1432
|
return `${task.id} (${task.status})`;
|
|
791
1433
|
}
|
|
792
|
-
function
|
|
793
|
-
const lines = [
|
|
1434
|
+
function formatSuccess2(result) {
|
|
1435
|
+
const lines = [buildResultTable2(result)];
|
|
794
1436
|
if (result.succeeded.length > 0) {
|
|
795
1437
|
lines.push("", "\uC0C1\uD0DC: IN_PROGRESS \u2192 \uC791\uC5C5\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4.");
|
|
796
1438
|
}
|
|
@@ -825,14 +1467,14 @@ async function runPlanApproveCommand(input, projectRoot) {
|
|
|
825
1467
|
if (result.succeeded.length === 0) {
|
|
826
1468
|
return {
|
|
827
1469
|
exitCode: 1,
|
|
828
|
-
stderr:
|
|
1470
|
+
stderr: buildResultTable2(result),
|
|
829
1471
|
stdout: ""
|
|
830
1472
|
};
|
|
831
1473
|
}
|
|
832
1474
|
return {
|
|
833
1475
|
exitCode: result.failed.length > 0 ? 1 : 0,
|
|
834
1476
|
stderr: result.failed.length > 0 ? "" : "",
|
|
835
|
-
stdout:
|
|
1477
|
+
stdout: formatSuccess2(result)
|
|
836
1478
|
};
|
|
837
1479
|
} catch (error) {
|
|
838
1480
|
return {
|
|
@@ -845,72 +1487,10 @@ async function runPlanApproveCommand(input, projectRoot) {
|
|
|
845
1487
|
|
|
846
1488
|
// src/commands/spec-approve.ts
|
|
847
1489
|
import { checkbox as checkbox3 } from "@inquirer/prompts";
|
|
848
|
-
|
|
849
|
-
// src/core/spec-approve.ts
|
|
850
|
-
import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
|
|
851
|
-
import { join as join6 } from "path";
|
|
852
|
-
function filterApprovalCandidates(tasks) {
|
|
853
|
-
return tasks.filter((task) => task.status === "PENDING_SPEC_APPROVAL");
|
|
854
|
-
}
|
|
855
|
-
function resolveTargetCandidates(tasks, target) {
|
|
856
|
-
const candidates = filterApprovalCandidates(tasks);
|
|
857
|
-
if (target === void 0 || target.trim() === "") {
|
|
858
|
-
return candidates;
|
|
859
|
-
}
|
|
860
|
-
const trimmedTarget = target.trim();
|
|
861
|
-
return candidates.filter(
|
|
862
|
-
(task) => task.id === trimmedTarget || task.slug === trimmedTarget || task.id.endsWith(trimmedTarget)
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
function validateSpecApprovalTargets(tasks) {
|
|
866
|
-
if (tasks.length === 0) {
|
|
867
|
-
throw new Error("No approvable spec tasks found.");
|
|
868
|
-
}
|
|
869
|
-
const invalidTask = tasks.find((task) => task.status !== "PENDING_SPEC_APPROVAL");
|
|
870
|
-
if (invalidTask !== void 0) {
|
|
871
|
-
throw new Error(
|
|
872
|
-
`Task ${invalidTask.id} is not awaiting spec approval (${invalidTask.status}).`
|
|
873
|
-
);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
function updateSpecApprovalBlock(metaContent, approvedAt) {
|
|
877
|
-
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: SPEC_APPROVED");
|
|
878
|
-
return withStatus.replace(
|
|
879
|
-
/spec:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
880
|
-
`spec:
|
|
881
|
-
approved: true
|
|
882
|
-
approved_at: ${approvedAt}`
|
|
883
|
-
);
|
|
884
|
-
}
|
|
885
|
-
async function approveSpecs(projectRoot, tasks, approvedAt) {
|
|
886
|
-
validateSpecApprovalTargets(tasks);
|
|
887
|
-
for (const task of tasks) {
|
|
888
|
-
const metaPath = join6(projectRoot, task.path, "meta.yml");
|
|
889
|
-
if (await getFsEntryKind(metaPath) !== "file") {
|
|
890
|
-
throw new Error(`Missing meta.yml for task ${task.id}.`);
|
|
891
|
-
}
|
|
892
|
-
const updatedContent = updateSpecApprovalBlock(await readFile5(metaPath, "utf8"), approvedAt);
|
|
893
|
-
await writeFile3(metaPath, updatedContent, "utf8");
|
|
894
|
-
}
|
|
895
|
-
return {
|
|
896
|
-
approvedAt,
|
|
897
|
-
approvedTaskIds: tasks.map((task) => task.id),
|
|
898
|
-
nextStatus: "SPEC_APPROVED"
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
async function loadSpecApprovalCandidates(projectRoot, input) {
|
|
902
|
-
const tasks = await listWorkspaceTasks(projectRoot);
|
|
903
|
-
return resolveTargetCandidates(tasks, input.target);
|
|
904
|
-
}
|
|
905
|
-
function createSpecApprovedAt(date = /* @__PURE__ */ new Date()) {
|
|
906
|
-
return formatUtcTimestamp(date);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// src/commands/spec-approve.ts
|
|
910
1490
|
function formatTaskLabel2(task) {
|
|
911
1491
|
return `${task.id} (${task.status})`;
|
|
912
1492
|
}
|
|
913
|
-
function
|
|
1493
|
+
function formatSuccess3(result, tasks) {
|
|
914
1494
|
const lines = ["\uC2A4\uD399 \uC2B9\uC778\uB428"];
|
|
915
1495
|
for (const task of tasks) {
|
|
916
1496
|
lines.push(`- ${task.path} -> ${result.nextStatus}`);
|
|
@@ -951,7 +1531,7 @@ async function runSpecApproveCommand(input, projectRoot) {
|
|
|
951
1531
|
return {
|
|
952
1532
|
exitCode: 0,
|
|
953
1533
|
stderr: "",
|
|
954
|
-
stdout:
|
|
1534
|
+
stdout: formatSuccess3(result, selectedTasks)
|
|
955
1535
|
};
|
|
956
1536
|
} catch (error) {
|
|
957
1537
|
return {
|
|
@@ -962,104 +1542,6 @@ async function runSpecApproveCommand(input, projectRoot) {
|
|
|
962
1542
|
}
|
|
963
1543
|
}
|
|
964
1544
|
|
|
965
|
-
// src/core/start.ts
|
|
966
|
-
import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
|
|
967
|
-
import { join as join7 } from "path";
|
|
968
|
-
function normalizeSlug(input) {
|
|
969
|
-
return input.trim().toLowerCase().replace(/[_\s]+/g, "-").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
970
|
-
}
|
|
971
|
-
function validateSlug(slug) {
|
|
972
|
-
if (slug === "") {
|
|
973
|
-
throw new Error("Invalid slug: slug cannot be empty after normalization.");
|
|
974
|
-
}
|
|
975
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
976
|
-
throw new Error("Invalid slug: use lowercase kebab-case only.");
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
function createWorkspaceId(date, type, slug) {
|
|
980
|
-
const year = String(date.getUTCFullYear());
|
|
981
|
-
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
982
|
-
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
983
|
-
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
984
|
-
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
985
|
-
return `${year}${month}${day}-${hour}${minute}-${type}-${slug}`;
|
|
986
|
-
}
|
|
987
|
-
function renderInitialMeta(input) {
|
|
988
|
-
return [
|
|
989
|
-
`id: ${input.id}`,
|
|
990
|
-
`type: ${input.type}`,
|
|
991
|
-
`slug: ${input.slug}`,
|
|
992
|
-
`created_at: ${input.createdAt}`,
|
|
993
|
-
"",
|
|
994
|
-
"status: PENDING_SPEC_APPROVAL",
|
|
995
|
-
"",
|
|
996
|
-
"spec:",
|
|
997
|
-
" approved: false",
|
|
998
|
-
" approved_at: null",
|
|
999
|
-
"",
|
|
1000
|
-
"plan:",
|
|
1001
|
-
" approved: false",
|
|
1002
|
-
" approved_at: null",
|
|
1003
|
-
"",
|
|
1004
|
-
"steps:",
|
|
1005
|
-
" total: null",
|
|
1006
|
-
" completed: []",
|
|
1007
|
-
"",
|
|
1008
|
-
"completed_at: null",
|
|
1009
|
-
""
|
|
1010
|
-
].join("\n");
|
|
1011
|
-
}
|
|
1012
|
-
async function resolveSpecTemplatePath(type) {
|
|
1013
|
-
const assetsRoot = await getBundledAssetsRoot();
|
|
1014
|
-
return join7(assetsRoot, resolveSpecTemplateRelativePath(type));
|
|
1015
|
-
}
|
|
1016
|
-
function applyTemplateDefaults(template, type, slug, currentDate) {
|
|
1017
|
-
const displayName = slug.replace(/-/g, " ");
|
|
1018
|
-
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}`);
|
|
1019
|
-
}
|
|
1020
|
-
async function startTask(rawType, rawSlug, projectRoot, currentDate = /* @__PURE__ */ new Date()) {
|
|
1021
|
-
if (!isSupportedTaskType(rawType)) {
|
|
1022
|
-
throw new Error(`Unsupported type: ${rawType}`);
|
|
1023
|
-
}
|
|
1024
|
-
const slug = normalizeSlug(rawSlug);
|
|
1025
|
-
validateSlug(slug);
|
|
1026
|
-
const activeTask = await findActiveTask(projectRoot);
|
|
1027
|
-
if (activeTask !== null) {
|
|
1028
|
-
throw new Error(
|
|
1029
|
-
`Active task exists: ${activeTask.id} (${activeTask.status}) at ${activeTask.path}. Finish or approve it before starting a new task.`
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
const workspaceId = createWorkspaceId(currentDate, rawType, slug);
|
|
1033
|
-
const workspacePath = join7("sduck-workspace", workspaceId);
|
|
1034
|
-
const absoluteWorkspacePath = join7(projectRoot, workspacePath);
|
|
1035
|
-
if (await getFsEntryKind(absoluteWorkspacePath) !== "missing") {
|
|
1036
|
-
throw new Error(`Workspace already exists: ${workspacePath}`);
|
|
1037
|
-
}
|
|
1038
|
-
const workspaceRoot = join7(projectRoot, "sduck-workspace");
|
|
1039
|
-
await mkdir3(workspaceRoot, { recursive: true });
|
|
1040
|
-
await mkdir3(absoluteWorkspacePath, { recursive: false });
|
|
1041
|
-
const templatePath = await resolveSpecTemplatePath(rawType);
|
|
1042
|
-
if (await getFsEntryKind(templatePath) !== "file") {
|
|
1043
|
-
throw new Error(`Missing spec template for type '${rawType}' at ${templatePath}`);
|
|
1044
|
-
}
|
|
1045
|
-
const specTemplate = await readFile6(templatePath, "utf8");
|
|
1046
|
-
const specContent = applyTemplateDefaults(specTemplate, rawType, slug, currentDate);
|
|
1047
|
-
const metaContent = renderInitialMeta({
|
|
1048
|
-
createdAt: formatUtcTimestamp(currentDate),
|
|
1049
|
-
id: workspaceId,
|
|
1050
|
-
slug,
|
|
1051
|
-
type: rawType
|
|
1052
|
-
});
|
|
1053
|
-
await writeFile4(join7(absoluteWorkspacePath, "meta.yml"), metaContent, "utf8");
|
|
1054
|
-
await writeFile4(join7(absoluteWorkspacePath, "spec.md"), specContent, "utf8");
|
|
1055
|
-
await writeFile4(join7(absoluteWorkspacePath, "plan.md"), "", "utf8");
|
|
1056
|
-
return {
|
|
1057
|
-
workspaceId,
|
|
1058
|
-
workspacePath,
|
|
1059
|
-
status: "PENDING_SPEC_APPROVAL"
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
1545
|
// src/commands/start.ts
|
|
1064
1546
|
async function runStartCommand(type, slug, projectRoot) {
|
|
1065
1547
|
try {
|
|
@@ -1082,8 +1564,70 @@ async function runStartCommand(type, slug, projectRoot) {
|
|
|
1082
1564
|
}
|
|
1083
1565
|
}
|
|
1084
1566
|
|
|
1567
|
+
// package.json
|
|
1568
|
+
var package_default = {
|
|
1569
|
+
name: "@sduck/sduck-cli",
|
|
1570
|
+
version: "0.1.2",
|
|
1571
|
+
description: "Spec-Driven Development CLI bootstrap",
|
|
1572
|
+
type: "module",
|
|
1573
|
+
bin: {
|
|
1574
|
+
sduck: "dist/cli.js"
|
|
1575
|
+
},
|
|
1576
|
+
files: [
|
|
1577
|
+
"dist",
|
|
1578
|
+
"sduck-assets"
|
|
1579
|
+
],
|
|
1580
|
+
engines: {
|
|
1581
|
+
node: ">=20"
|
|
1582
|
+
},
|
|
1583
|
+
scripts: {
|
|
1584
|
+
dev: "tsx src/cli.ts",
|
|
1585
|
+
build: "tsup src/cli.ts --config tsup.config.ts",
|
|
1586
|
+
lint: "eslint . --max-warnings=0",
|
|
1587
|
+
format: "prettier --write .",
|
|
1588
|
+
"format:check": "prettier --check .",
|
|
1589
|
+
typecheck: "tsc --project tsconfig.json --noEmit",
|
|
1590
|
+
"test:unit": "vitest run tests/unit",
|
|
1591
|
+
"test:e2e": "vitest run tests/e2e",
|
|
1592
|
+
test: "npm run test:unit && npm run test:e2e",
|
|
1593
|
+
prepare: "husky"
|
|
1594
|
+
},
|
|
1595
|
+
"lint-staged": {
|
|
1596
|
+
"*.{js,mjs,cjs,ts,mts,cts}": [
|
|
1597
|
+
"eslint --fix",
|
|
1598
|
+
"prettier --write"
|
|
1599
|
+
],
|
|
1600
|
+
"*.{json,md,yml,yaml}": [
|
|
1601
|
+
"prettier --write"
|
|
1602
|
+
]
|
|
1603
|
+
},
|
|
1604
|
+
dependencies: {
|
|
1605
|
+
"@inquirer/prompts": "^7.8.6",
|
|
1606
|
+
commander: "^14.0.1",
|
|
1607
|
+
"js-yaml": "^4.1.0"
|
|
1608
|
+
},
|
|
1609
|
+
devDependencies: {
|
|
1610
|
+
"@eslint/js": "^9.23.0",
|
|
1611
|
+
"@types/node": "^22.13.14",
|
|
1612
|
+
eslint: "^9.23.0",
|
|
1613
|
+
"eslint-config-prettier": "^10.1.1",
|
|
1614
|
+
"eslint-plugin-import": "^2.31.0",
|
|
1615
|
+
"eslint-import-resolver-typescript": "^4.3.5",
|
|
1616
|
+
"eslint-plugin-n": "^17.16.2",
|
|
1617
|
+
husky: "^9.1.7",
|
|
1618
|
+
"lint-staged": "^15.5.0",
|
|
1619
|
+
prettier: "^3.5.3",
|
|
1620
|
+
tsup: "^8.4.0",
|
|
1621
|
+
tsx: "^4.19.3",
|
|
1622
|
+
typescript: "^5.8.2",
|
|
1623
|
+
"typescript-eslint": "^8.28.0",
|
|
1624
|
+
vitest: "^3.0.8"
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1085
1628
|
// src/core/command-metadata.ts
|
|
1086
1629
|
var CLI_NAME = "sduck";
|
|
1630
|
+
var CLI_VERSION = package_default.version;
|
|
1087
1631
|
var CLI_DESCRIPTION = "Spec-Driven Development workflow bootstrap CLI";
|
|
1088
1632
|
var PLACEHOLDER_MESSAGE = "Core workflow commands are planned but not implemented in this bootstrap yet.";
|
|
1089
1633
|
function normalizeCommandName(input) {
|
|
@@ -1092,8 +1636,8 @@ function normalizeCommandName(input) {
|
|
|
1092
1636
|
|
|
1093
1637
|
// src/cli.ts
|
|
1094
1638
|
var program = new Command();
|
|
1095
|
-
program.name(CLI_NAME).description(CLI_DESCRIPTION).version(
|
|
1096
|
-
program.command("init").description("Initialize the current repository for the SDD workflow").option("--force", "Regenerate the bundled assets in sduck-assets").option(
|
|
1639
|
+
program.name(CLI_NAME).description(CLI_DESCRIPTION).version(CLI_VERSION);
|
|
1640
|
+
program.command("init").description("Initialize the current repository for the SDD workflow").option("--force", "Regenerate the bundled assets in .sduck/sduck-assets").option(
|
|
1097
1641
|
"--agents <agents>",
|
|
1098
1642
|
"Comma-separated agents (claude-code,codex,opencode,gemini-cli,cursor,antigravity)"
|
|
1099
1643
|
).action(async (options) => {
|
|
@@ -1124,6 +1668,18 @@ program.command("start <type> <slug>").description("Create a new task workspace
|
|
|
1124
1668
|
process.exitCode = result.exitCode;
|
|
1125
1669
|
}
|
|
1126
1670
|
});
|
|
1671
|
+
program.command("fast-track <type> <slug>").description("Create a minimal spec/plan task with optional bundled approval").action(async (type, slug) => {
|
|
1672
|
+
const result = await runFastTrackCommand({ slug, type }, process.cwd());
|
|
1673
|
+
if (result.stdout !== "") {
|
|
1674
|
+
console.log(result.stdout);
|
|
1675
|
+
}
|
|
1676
|
+
if (result.stderr !== "") {
|
|
1677
|
+
console.error(result.stderr);
|
|
1678
|
+
}
|
|
1679
|
+
if (result.exitCode !== 0) {
|
|
1680
|
+
process.exitCode = result.exitCode;
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1127
1683
|
program.command("spec").description("Manage spec workflow state").command("approve [target]").description("Approve a task spec and move it to plan writing").action(async (target) => {
|
|
1128
1684
|
const input = target === void 0 ? {} : { target };
|
|
1129
1685
|
const result = await runSpecApproveCommand(input, process.cwd());
|
|
@@ -1150,6 +1706,19 @@ program.command("plan").description("Manage plan workflow state").command("appro
|
|
|
1150
1706
|
process.exitCode = result.exitCode;
|
|
1151
1707
|
}
|
|
1152
1708
|
});
|
|
1709
|
+
program.command("done [target]").description("Complete an in-progress task after validation").action(async (target) => {
|
|
1710
|
+
const input = target === void 0 ? {} : { target };
|
|
1711
|
+
const result = await runDoneCommand(input, process.cwd());
|
|
1712
|
+
if (result.stdout !== "") {
|
|
1713
|
+
console.log(result.stdout);
|
|
1714
|
+
}
|
|
1715
|
+
if (result.stderr !== "") {
|
|
1716
|
+
console.error(result.stderr);
|
|
1717
|
+
}
|
|
1718
|
+
if (result.exitCode !== 0) {
|
|
1719
|
+
process.exitCode = result.exitCode;
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1153
1722
|
program.command("roadmap").description("Show the current bootstrap status").action(() => {
|
|
1154
1723
|
console.log(PLACEHOLDER_MESSAGE);
|
|
1155
1724
|
});
|