dsh-plugin-cc 0.1.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +11 -0
  4. package/README.md +96 -0
  5. package/package.json +12 -0
  6. package/plugins/dsh/.claude-plugin/plugin.json +6 -0
  7. package/plugins/dsh/CHANGELOG.md +8 -0
  8. package/plugins/dsh/LICENSE +201 -0
  9. package/plugins/dsh/NOTICE +11 -0
  10. package/plugins/dsh/agents/dsh-rescue.md +41 -0
  11. package/plugins/dsh/commands/cancel.md +8 -0
  12. package/plugins/dsh/commands/plan.md +54 -0
  13. package/plugins/dsh/commands/rescue.md +51 -0
  14. package/plugins/dsh/commands/result.md +15 -0
  15. package/plugins/dsh/commands/review-plan.md +69 -0
  16. package/plugins/dsh/commands/setup.md +37 -0
  17. package/plugins/dsh/commands/status.md +17 -0
  18. package/plugins/dsh/hooks/hooks.json +27 -0
  19. package/plugins/dsh/prompts/plan.md +21 -0
  20. package/plugins/dsh/prompts/review-plan.md +25 -0
  21. package/plugins/dsh/scripts/dsh-companion.mjs +720 -0
  22. package/plugins/dsh/scripts/lib/args.mjs +130 -0
  23. package/plugins/dsh/scripts/lib/dsh.mjs +238 -0
  24. package/plugins/dsh/scripts/lib/fs.mjs +40 -0
  25. package/plugins/dsh/scripts/lib/git.mjs +14 -0
  26. package/plugins/dsh/scripts/lib/job-control.mjs +314 -0
  27. package/plugins/dsh/scripts/lib/plans.mjs +70 -0
  28. package/plugins/dsh/scripts/lib/process.mjs +156 -0
  29. package/plugins/dsh/scripts/lib/prompts.mjs +13 -0
  30. package/plugins/dsh/scripts/lib/render.mjs +231 -0
  31. package/plugins/dsh/scripts/lib/state.mjs +258 -0
  32. package/plugins/dsh/scripts/lib/tracked-jobs.mjs +242 -0
  33. package/plugins/dsh/scripts/lib/workspace.mjs +9 -0
  34. package/plugins/dsh/scripts/session-lifecycle-hook.mjs +116 -0
  35. package/plugins/dsh/skills/dsh-cli-runtime/SKILL.md +40 -0
  36. package/plugins/dsh/skills/dsh-result-handling/SKILL.md +19 -0
@@ -0,0 +1,314 @@
1
+ import fs from "node:fs";
2
+
3
+ import { isProcessAlive, terminateProcessTree } from "./process.mjs";
4
+ import { listJobs, readJobFile, resolveJobFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs";
5
+ import { nowIso, SESSION_ID_ENV } from "./tracked-jobs.mjs";
6
+ import { resolveWorkspaceRoot } from "./workspace.mjs";
7
+
8
+ export const DEFAULT_MAX_STATUS_JOBS = 8;
9
+ export const DEFAULT_MAX_PROGRESS_LINES = 4;
10
+
11
+ /**
12
+ * Marks active jobs whose companion process has died (for example after SIGKILL) as failed and stops
13
+ * any dsh process group they left behind, so they cannot block resume or hide from cancel.
14
+ */
15
+ export function reconcileStaleJobs(workspaceRoot) {
16
+ withStateLock(workspaceRoot, () => {
17
+ for (const job of listJobs(workspaceRoot)) {
18
+ const active = job.status === "queued" || job.status === "running";
19
+ if (!active || !Number.isFinite(job.pid) || isProcessAlive(job.pid)) {
20
+ continue;
21
+ }
22
+ const failure = {
23
+ status: "failed",
24
+ phase: "failed",
25
+ pid: null,
26
+ errorMessage: "The companion process ended unexpectedly.",
27
+ completedAt: nowIso()
28
+ };
29
+ const jobFile = resolveJobFile(workspaceRoot, job.id);
30
+ const stored = fs.existsSync(jobFile) ? readJobFile(jobFile) : job;
31
+ writeJobFile(workspaceRoot, job.id, { ...stored, ...failure });
32
+ upsertJob(workspaceRoot, { id: job.id, ...failure });
33
+ if (Number.isFinite(job.dshPid)) {
34
+ terminateProcessTree(job.dshPid);
35
+ }
36
+ }
37
+ });
38
+ }
39
+
40
+ export function sortJobsNewestFirst(jobs) {
41
+ return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
42
+ }
43
+
44
+ function getCurrentSessionId(options = {}) {
45
+ return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null;
46
+ }
47
+
48
+ function filterJobsForCurrentSession(jobs, options = {}) {
49
+ const sessionId = getCurrentSessionId(options);
50
+ if (!sessionId) {
51
+ return jobs;
52
+ }
53
+ return jobs.filter((job) => job.sessionId === sessionId);
54
+ }
55
+
56
+ function getJobTypeLabel(job) {
57
+ if (typeof job.kindLabel === "string" && job.kindLabel) {
58
+ return job.kindLabel;
59
+ }
60
+ if (job.kind === "plan" || job.kind === "review-plan") {
61
+ return job.kind;
62
+ }
63
+ if (job.kind === "task") {
64
+ return "rescue";
65
+ }
66
+ return "job";
67
+ }
68
+
69
+ function stripLogPrefix(line) {
70
+ return line.replace(/^\[[^\]]+\]\s*/, "").trim();
71
+ }
72
+
73
+ function isProgressBlockTitle(line) {
74
+ return (
75
+ line === "Final output"
76
+ );
77
+ }
78
+
79
+ export function readJobProgressPreview(logFile, maxLines = DEFAULT_MAX_PROGRESS_LINES) {
80
+ if (!logFile || !fs.existsSync(logFile)) {
81
+ return [];
82
+ }
83
+
84
+ const lines = fs
85
+ .readFileSync(logFile, "utf8")
86
+ .split(/\r?\n/)
87
+ .map((line) => line.trimEnd())
88
+ .filter(Boolean)
89
+ .filter((line) => line.startsWith("["))
90
+ .map(stripLogPrefix)
91
+ .filter((line) => line && !isProgressBlockTitle(line));
92
+
93
+ return lines.slice(-maxLines);
94
+ }
95
+
96
+ function formatElapsedDuration(startValue, endValue = null) {
97
+ const start = Date.parse(startValue ?? "");
98
+ if (!Number.isFinite(start)) {
99
+ return null;
100
+ }
101
+
102
+ const end = endValue ? Date.parse(endValue) : Date.now();
103
+ if (!Number.isFinite(end) || end < start) {
104
+ return null;
105
+ }
106
+
107
+ const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
108
+ const hours = Math.floor(totalSeconds / 3600);
109
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
110
+ const seconds = totalSeconds % 60;
111
+
112
+ if (hours > 0) {
113
+ return `${hours}h ${minutes}m`;
114
+ }
115
+ if (minutes > 0) {
116
+ return `${minutes}m ${seconds}s`;
117
+ }
118
+ return `${seconds}s`;
119
+ }
120
+
121
+ function inferLegacyJobPhase(job) {
122
+ switch (job.status) {
123
+ case "queued":
124
+ return "queued";
125
+ case "cancelled":
126
+ return "cancelled";
127
+ case "failed":
128
+ return "failed";
129
+ case "completed":
130
+ return "done";
131
+ default:
132
+ return "running";
133
+ }
134
+ }
135
+
136
+ export function enrichJob(job, options = {}) {
137
+ const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
138
+ const enriched = {
139
+ ...job,
140
+ kindLabel: getJobTypeLabel(job),
141
+ progressPreview:
142
+ job.status === "queued" || job.status === "running" || job.status === "failed"
143
+ ? readJobProgressPreview(job.logFile, maxProgressLines)
144
+ : [],
145
+ elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
146
+ duration:
147
+ job.status === "completed" || job.status === "failed" || job.status === "cancelled"
148
+ ? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt)
149
+ : null
150
+ };
151
+
152
+ return {
153
+ ...enriched,
154
+ phase: enriched.phase ?? inferLegacyJobPhase(enriched)
155
+ };
156
+ }
157
+
158
+ export function readStoredJob(workspaceRoot, jobId) {
159
+ const jobFile = resolveJobFile(workspaceRoot, jobId);
160
+ if (!fs.existsSync(jobFile)) {
161
+ return null;
162
+ }
163
+ return readJobFile(jobFile);
164
+ }
165
+
166
+ function matchJobReference(jobs, reference, predicate = () => true) {
167
+ const filtered = jobs.filter(predicate);
168
+ if (!reference) {
169
+ return filtered[0] ?? null;
170
+ }
171
+
172
+ const exact = filtered.find((job) => job.id === reference);
173
+ if (exact) {
174
+ return exact;
175
+ }
176
+
177
+ const prefixMatches = filtered.filter((job) => job.id.startsWith(reference));
178
+ if (prefixMatches.length === 1) {
179
+ return prefixMatches[0];
180
+ }
181
+ if (prefixMatches.length > 1) {
182
+ throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
183
+ }
184
+
185
+ throw new Error(`No job found for "${reference}". Run /dsh:status to list known jobs.`);
186
+ }
187
+
188
+ export function buildStatusSnapshot(cwd, options = {}) {
189
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
190
+ reconcileStaleJobs(workspaceRoot);
191
+ const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), options));
192
+ const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
193
+ const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
194
+
195
+ const running = jobs
196
+ .filter((job) => job.status === "queued" || job.status === "running")
197
+ .map((job) => enrichJob(job, { maxProgressLines }));
198
+
199
+ const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null;
200
+ const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null;
201
+
202
+ const recent = (options.all ? jobs : jobs.slice(0, maxJobs))
203
+ .filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id)
204
+ .map((job) => enrichJob(job, { maxProgressLines }));
205
+
206
+ return {
207
+ workspaceRoot,
208
+ running,
209
+ latestFinished,
210
+ recent
211
+ };
212
+ }
213
+
214
+ export function buildSingleJobSnapshot(cwd, reference, options = {}) {
215
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
216
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
217
+ const selected = matchJobReference(jobs, reference);
218
+ if (!selected) {
219
+ throw new Error(`No job found for "${reference}". Run /dsh:status to inspect known jobs.`);
220
+ }
221
+
222
+ return {
223
+ workspaceRoot,
224
+ job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
225
+ };
226
+ }
227
+
228
+ export function resolveResultJob(cwd, reference) {
229
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
230
+ const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
231
+ const selected = matchJobReference(
232
+ jobs,
233
+ reference,
234
+ (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
235
+ );
236
+
237
+ if (selected) {
238
+ return { workspaceRoot, job: selected };
239
+ }
240
+
241
+ const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
242
+ if (active) {
243
+ throw new Error(`Job ${active.id} is still ${active.status}. Check /dsh:status and try again once it finishes.`);
244
+ }
245
+
246
+ if (reference) {
247
+ throw new Error(`No finished job found for "${reference}". Run /dsh:status to inspect active jobs.`);
248
+ }
249
+
250
+ throw new Error("No finished dsh jobs found for this repository yet.");
251
+ }
252
+
253
+ export function resolveCancelableJob(cwd, reference, options = {}) {
254
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
255
+ reconcileStaleJobs(workspaceRoot);
256
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
257
+ const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
258
+
259
+ if (reference) {
260
+ const selected = matchJobReference(activeJobs, reference);
261
+ if (!selected) {
262
+ throw new Error(`No active job found for "${reference}".`);
263
+ }
264
+ return { workspaceRoot, job: selected };
265
+ }
266
+
267
+ const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
268
+
269
+ if (sessionScopedActiveJobs.length === 1) {
270
+ return { workspaceRoot, job: sessionScopedActiveJobs[0] };
271
+ }
272
+ if (sessionScopedActiveJobs.length > 1) {
273
+ throw new Error("Multiple dsh jobs are active. Pass a job id to /dsh:cancel.");
274
+ }
275
+
276
+ if (getCurrentSessionId(options)) {
277
+ throw new Error("No active dsh jobs to cancel for this session.");
278
+ }
279
+
280
+ throw new Error("No active dsh jobs to cancel.");
281
+ }
282
+
283
+ function isActiveStatus(status) {
284
+ return status === "queued" || status === "running";
285
+ }
286
+
287
+ /**
288
+ * Finds the rescue job a resume should continue. Shared by `task --resume-last` and
289
+ * `task-resume-candidate` so the picker and the executor can never disagree.
290
+ * Only jobs of this Claude session and this cwd count, because dsh refuses to adopt a session
291
+ * recorded under another cwd. Returns null when there is no candidate.
292
+ */
293
+ export function resolveResumableTask(cwd, options = {}) {
294
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
295
+ const sessionId = getCurrentSessionId(options);
296
+ if (!sessionId) {
297
+ // Without a Claude session id every session would see every other session's rescue jobs.
298
+ return null;
299
+ }
300
+ reconcileStaleJobs(workspaceRoot);
301
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter(
302
+ (job) =>
303
+ job.kind === "task" && job.id !== options.excludeJobId && job.sessionId === sessionId && job.cwd === cwd
304
+ );
305
+
306
+ const running = jobs.find((job) => isActiveStatus(job.status));
307
+ if (running) {
308
+ throw new Error(`Task ${running.id} is still running.`);
309
+ }
310
+ // The newest rescue run is the one the user means. If it never got a dsh session, there is nothing
311
+ // to resume; an older session must not be continued in its place.
312
+ const newest = jobs[0];
313
+ return newest?.dshSessionId ? newest : null;
314
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const SKIPPED_DIRS = new Set(["reports", "archive"]);
5
+
6
+ // Symlinks are skipped: a plan folder must not be able to pull in files from elsewhere on disk.
7
+ function isPlainFile(file) {
8
+ try {
9
+ return fs.lstatSync(file).isFile();
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function listPhaseFiles(dir) {
16
+ return fs
17
+ .readdirSync(dir)
18
+ .filter((name) => /^phase-.*\.md$/.test(name) && isPlainFile(path.join(dir, name)))
19
+ .sort();
20
+ }
21
+
22
+ function newestMtimeMs(dir) {
23
+ const names = ["plan.md", ...listPhaseFiles(dir)];
24
+ return Math.max(...names.map((name) => fs.statSync(path.join(dir, name)).mtimeMs));
25
+ }
26
+
27
+ /**
28
+ * Returns the plan directory under `<root>/plans/` that changed most recently, relative to `root`,
29
+ * or null. A plan directory contains `plan.md`. `reports/` and `archive/` are skipped.
30
+ */
31
+ export function findLatestPlanDir(root) {
32
+ const plansDir = path.join(root, "plans");
33
+ if (!fs.existsSync(plansDir)) {
34
+ return null;
35
+ }
36
+
37
+ let latest = null;
38
+ for (const entry of fs.readdirSync(plansDir, { withFileTypes: true })) {
39
+ if (!entry.isDirectory() || SKIPPED_DIRS.has(entry.name)) {
40
+ continue;
41
+ }
42
+ const dir = path.join(plansDir, entry.name);
43
+ if (!isPlainFile(path.join(dir, "plan.md"))) {
44
+ continue;
45
+ }
46
+ const mtime = newestMtimeMs(dir);
47
+ if (!latest || mtime > latest.mtime) {
48
+ latest = { name: entry.name, mtime };
49
+ }
50
+ }
51
+ return latest ? path.join("plans", latest.name) : null;
52
+ }
53
+
54
+ /**
55
+ * Resolves a plan path to the files to review. A file yields itself. A directory yields `plan.md`
56
+ * followed by every `phase-*.md` in name order. Throws when the path or `plan.md` is missing.
57
+ */
58
+ export function collectPlanFiles(absolutePath) {
59
+ if (!fs.existsSync(absolutePath)) {
60
+ throw new Error(`Plan path not found: ${absolutePath}`);
61
+ }
62
+ if (!fs.statSync(absolutePath).isDirectory()) {
63
+ return [absolutePath];
64
+ }
65
+ const planFile = path.join(absolutePath, "plan.md");
66
+ if (!isPlainFile(planFile)) {
67
+ throw new Error(`No plan.md in ${absolutePath}. Pass a plan file or a directory that has plan.md.`);
68
+ }
69
+ return [planFile, ...listPhaseFiles(absolutePath).map((name) => path.join(absolutePath, name))];
70
+ }
@@ -0,0 +1,156 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ export function runCommand(command, args = [], options = {}) {
5
+ const result = spawnSync(command, args, {
6
+ cwd: options.cwd,
7
+ env: options.env,
8
+ encoding: "utf8",
9
+ input: options.input,
10
+ maxBuffer: options.maxBuffer,
11
+ stdio: options.stdio ?? "pipe",
12
+ shell: options.shell ?? (process.platform === "win32" ? (process.env.SHELL || true) : false),
13
+ windowsHide: true
14
+ });
15
+
16
+ return {
17
+ command,
18
+ args,
19
+ status: result.status ?? 0,
20
+ signal: result.signal ?? null,
21
+ stdout: result.stdout ?? "",
22
+ stderr: result.stderr ?? "",
23
+ error: result.error ?? null
24
+ };
25
+ }
26
+
27
+ export function runCommandChecked(command, args = [], options = {}) {
28
+ const result = runCommand(command, args, options);
29
+ if (result.error) {
30
+ throw result.error;
31
+ }
32
+ if (result.status !== 0) {
33
+ throw new Error(formatCommandFailure(result));
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function binaryAvailable(command, versionArgs = ["--version"], options = {}) {
39
+ const result = runCommand(command, versionArgs, options);
40
+ if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") {
41
+ return { available: false, detail: "not found" };
42
+ }
43
+ if (result.error) {
44
+ return { available: false, detail: result.error.message };
45
+ }
46
+ if (result.status !== 0) {
47
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
48
+ return { available: false, detail };
49
+ }
50
+ return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" };
51
+ }
52
+
53
+ function looksLikeMissingProcessMessage(text) {
54
+ return /not found|no running instance|cannot find|does not exist|no such process/i.test(text);
55
+ }
56
+
57
+ export function terminateProcessTree(pid, options = {}) {
58
+ if (!Number.isFinite(pid)) {
59
+ return { attempted: false, delivered: false, method: null };
60
+ }
61
+
62
+ const platform = options.platform ?? process.platform;
63
+ const runCommandImpl = options.runCommandImpl ?? runCommand;
64
+ const killImpl = options.killImpl ?? process.kill.bind(process);
65
+
66
+ if (platform === "win32") {
67
+ const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], {
68
+ cwd: options.cwd,
69
+ env: options.env
70
+ });
71
+
72
+ if (!result.error && result.status === 0) {
73
+ return { attempted: true, delivered: true, method: "taskkill", result };
74
+ }
75
+
76
+ const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
77
+ if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) {
78
+ return { attempted: true, delivered: false, method: "taskkill", result };
79
+ }
80
+
81
+ if (result.error?.code === "ENOENT") {
82
+ try {
83
+ killImpl(pid);
84
+ return { attempted: true, delivered: true, method: "kill" };
85
+ } catch (error) {
86
+ if (error?.code === "ESRCH") {
87
+ return { attempted: true, delivered: false, method: "kill" };
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ if (result.error) {
94
+ throw result.error;
95
+ }
96
+
97
+ throw new Error(formatCommandFailure(result));
98
+ }
99
+
100
+ try {
101
+ killImpl(-pid, "SIGTERM");
102
+ return { attempted: true, delivered: true, method: "process-group" };
103
+ } catch (error) {
104
+ if (error?.code !== "ESRCH") {
105
+ try {
106
+ killImpl(pid, "SIGTERM");
107
+ return { attempted: true, delivered: true, method: "process" };
108
+ } catch (innerError) {
109
+ if (innerError?.code === "ESRCH") {
110
+ return { attempted: true, delivered: false, method: "process" };
111
+ }
112
+ throw innerError;
113
+ }
114
+ }
115
+
116
+ return { attempted: true, delivered: false, method: "process-group" };
117
+ }
118
+ }
119
+
120
+ export function formatCommandFailure(result) {
121
+ const parts = [`${result.command} ${result.args.join(" ")}`.trim()];
122
+ if (result.signal) {
123
+ parts.push(`signal=${result.signal}`);
124
+ } else {
125
+ parts.push(`exit=${result.status}`);
126
+ }
127
+ const stderr = (result.stderr || "").trim();
128
+ const stdout = (result.stdout || "").trim();
129
+ if (stderr) {
130
+ parts.push(stderr);
131
+ } else if (stdout) {
132
+ parts.push(stdout);
133
+ }
134
+ return parts.join(": ");
135
+ }
136
+
137
+ export function isProcessAlive(pid) {
138
+ if (!Number.isFinite(pid)) {
139
+ return false;
140
+ }
141
+ try {
142
+ process.kill(pid, 0);
143
+ return true;
144
+ } catch (error) {
145
+ return error?.code === "EPERM";
146
+ }
147
+ }
148
+
149
+ // Used before signalling a recorded pid, so a reused pid of an unrelated process is never killed.
150
+ export function processCommandIncludes(pid, needle) {
151
+ if (!Number.isFinite(pid) || process.platform === "win32") {
152
+ return isProcessAlive(pid);
153
+ }
154
+ const result = runCommand("ps", ["-o", "command=", "-p", String(pid)], { shell: false });
155
+ return result.status === 0 && result.stdout.includes(needle);
156
+ }
@@ -0,0 +1,13 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function loadPromptTemplate(rootDir, name) {
5
+ const promptPath = path.join(rootDir, "prompts", `${name}.md`);
6
+ return fs.readFileSync(promptPath, "utf8");
7
+ }
8
+
9
+ export function interpolateTemplate(template, variables) {
10
+ return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
11
+ return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : "";
12
+ });
13
+ }