deuk-agent-rule 2.5.13 → 3.3.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/CHANGELOG.ko.md +74 -0
- package/CHANGELOG.md +138 -316
- package/README.ko.md +134 -154
- package/README.md +121 -153
- package/package.json +29 -7
- package/scripts/cli-args.mjs +87 -3
- package/scripts/cli-init-commands.mjs +1382 -223
- package/scripts/cli-init-logic.mjs +28 -16
- package/scripts/cli-prompts.mjs +13 -4
- package/scripts/cli-rule-compiler.mjs +44 -34
- package/scripts/cli-skill-commands.mjs +172 -0
- package/scripts/cli-telemetry-commands.mjs +429 -0
- package/scripts/cli-ticket-commands.mjs +1934 -161
- package/scripts/cli-ticket-index.mjs +298 -0
- package/scripts/cli-ticket-migration.mjs +320 -0
- package/scripts/cli-ticket-parser.mjs +207 -0
- package/scripts/cli-utils.mjs +381 -59
- package/scripts/cli.mjs +99 -19
- package/scripts/lint-md.mjs +247 -0
- package/scripts/lint-rules.mjs +143 -0
- package/scripts/merge-logic.mjs +13 -306
- package/scripts/plan-parser.mjs +53 -0
- package/templates/MODULE_RULE_TEMPLATE.md +11 -0
- package/templates/PROJECT_RULE.md +47 -0
- package/templates/TICKET_TEMPLATE.ko.md +21 -0
- package/templates/TICKET_TEMPLATE.md +21 -0
- package/templates/rules.d/deukcontext-mcp.md +31 -0
- package/templates/rules.d/platform-coexistence.md +29 -0
- package/templates/skills/context-recall/SKILL.md +25 -0
- package/templates/skills/generated-file-guard/SKILL.md +25 -0
- package/templates/skills/safe-refactor/SKILL.md +25 -0
- package/bundle/.cursorrules +0 -11
- package/bundle/AGENTS.md +0 -146
- package/bundle/gemini.md +0 -26
- package/bundle/rules/delivery-and-parallel-work.mdc +0 -26
- package/bundle/rules/git-commit.mdc +0 -24
- package/bundle/rules/multi-ai-workflow.mdc +0 -104
- package/bundle/rules.d/core-workflow.md +0 -48
- package/bundle/rules.d/deukrag-mcp.md +0 -37
- package/bundle/templates/MODULE_RULE_TEMPLATE.md +0 -24
- package/bundle/templates/TICKET_TEMPLATE.md +0 -58
- package/scripts/cli-ticket-logic.mjs +0 -568
- package/scripts/sync-bundle.mjs +0 -77
- package/scripts/sync-oss.mjs +0 -126
package/scripts/cli-utils.mjs
CHANGED
|
@@ -1,19 +1,89 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, readdirSync } from "fs";
|
|
2
|
-
import { basename, dirname, join, relative } from "path";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, rmSync } from "fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { createInterface } from "readline";
|
|
3
5
|
import YAML from "yaml";
|
|
4
6
|
|
|
7
|
+
/** Converts an absolute path to a clickable file:/// URI */
|
|
8
|
+
export function toFileUri(absPath) {
|
|
9
|
+
return pathToFileURL(absPath).href;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export const AGENT_ROOT_DIR = ".deuk-agent";
|
|
6
13
|
export const TICKET_SUBDIR = "tickets";
|
|
7
14
|
export const TEMPLATE_SUBDIR = "templates";
|
|
8
15
|
export const RULES_SUBDIR = "rules";
|
|
16
|
+
export const WORKFLOW_MODE_PLAN = "plan";
|
|
17
|
+
export const WORKFLOW_MODE_EXECUTE = "execute";
|
|
9
18
|
|
|
10
19
|
export const TICKET_DIR_NAME = `${AGENT_ROOT_DIR}/${TICKET_SUBDIR}`;
|
|
11
20
|
export const TICKET_INDEX_FILENAME = "INDEX.json";
|
|
12
21
|
export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
|
|
13
|
-
export const
|
|
22
|
+
export const DOCS_SUBDIR = "docs";
|
|
23
|
+
export const PLANS_SUBDIR = "plan";
|
|
24
|
+
export const PLAN_LINKS_DIR = `${AGENT_ROOT_DIR}/${DOCS_SUBDIR}/${PLANS_SUBDIR}`;
|
|
25
|
+
|
|
26
|
+
export const LEGACY_TEMPLATE_DIR = ".deuk-agent-templates";
|
|
27
|
+
export const LEGACY_TICKET_DIR = ".deuk-agent-ticket";
|
|
28
|
+
export const LEGACY_TICKET_DIR_PLURAL = ".deuk-agent-tickets";
|
|
29
|
+
export const LEGACY_TICKET_DIR_ROOT = "ticket";
|
|
30
|
+
export const LEGACY_CONFIG_FILE = ".deuk-agent-rule.config.json";
|
|
31
|
+
export const LEGACY_IGNORE_DIR = `${AGENT_ROOT_DIR}/tickets/`;
|
|
32
|
+
export const ARCHIVE_YEAR_MONTH_RE = /^\d{4}-\d{2}$/;
|
|
33
|
+
export const ARCHIVE_DAY_RE = /^\d{2}$/;
|
|
34
|
+
|
|
35
|
+
const LEGACY_TICKET_GROUPS = new Set([
|
|
36
|
+
LEGACY_TICKET_DIR,
|
|
37
|
+
LEGACY_TICKET_DIR_PLURAL,
|
|
38
|
+
"ticket",
|
|
39
|
+
"tickets"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Computes the canonical repository-relative path for a ticket based on its state.
|
|
44
|
+
*/
|
|
45
|
+
export function computeTicketPath(entry) {
|
|
46
|
+
const isArchived = entry.status === "archived";
|
|
47
|
+
const group = normalizeTicketGroup(entry.group, "sub");
|
|
48
|
+
const fileStem = entry.fileName
|
|
49
|
+
? String(entry.fileName).replace(/\.md$/i, "")
|
|
50
|
+
: (entry.group === TICKET_SUBDIR && entry.topic ? entry.topic : entry.id);
|
|
51
|
+
|
|
52
|
+
if (!isArchived && group === TICKET_SUBDIR) {
|
|
53
|
+
return [TICKET_DIR_NAME, `${fileStem}.md`].join("/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isArchived && entry.archiveYearMonth && entry.archiveDay) {
|
|
57
|
+
return [
|
|
58
|
+
TICKET_DIR_NAME,
|
|
59
|
+
"archive",
|
|
60
|
+
group,
|
|
61
|
+
entry.archiveYearMonth,
|
|
62
|
+
entry.archiveDay,
|
|
63
|
+
`${fileStem}.md`
|
|
64
|
+
].join("/");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parts = [
|
|
68
|
+
TICKET_DIR_NAME,
|
|
69
|
+
isArchived ? "archive" : null,
|
|
70
|
+
group,
|
|
71
|
+
`${fileStem}.md`
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
return parts.join("/");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeTicketGroup(rawGroup, fallback = "sub") {
|
|
77
|
+
const value = String(rawGroup || "").trim();
|
|
78
|
+
if (!value) return fallback;
|
|
79
|
+
if (value.includes("/") || value.startsWith("TICKET-") && value.includes(".md")) return fallback;
|
|
80
|
+
if (value.endsWith(".md")) return fallback;
|
|
81
|
+
if (value.endsWith(".markdown")) return fallback;
|
|
82
|
+
if (LEGACY_TICKET_GROUPS.has(value)) return fallback;
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
14
85
|
|
|
15
86
|
export const INIT_CONFIG_FILENAME = `${AGENT_ROOT_DIR}/config.json`;
|
|
16
|
-
export const LEGACY_INIT_CONFIG_FILENAME = ".deuk-agent-rule.config.json";
|
|
17
87
|
export const INIT_CONFIG_VERSION = 1;
|
|
18
88
|
|
|
19
89
|
export const STACKS = [
|
|
@@ -27,42 +97,176 @@ export const STACKS = [
|
|
|
27
97
|
|
|
28
98
|
export const AGENT_TOOLS = [
|
|
29
99
|
{ label: "Cursor (Rule System)", value: "cursor" },
|
|
100
|
+
{ label: "GitHub Copilot", value: "copilot" },
|
|
101
|
+
{ label: "Codex / OpenAI", value: "codex" },
|
|
30
102
|
{ label: "Gemini / Antigravity", value: "gemini" },
|
|
31
103
|
{ label: "Claude / Dev", value: "claude" },
|
|
32
104
|
];
|
|
33
105
|
|
|
106
|
+
export const SPOKE_REGISTRY = [
|
|
107
|
+
{ id: "cursor", detect: (cwd) => existsSync(join(cwd, ".cursor")), legacy: ".cursorrules", target: ".cursor/rules/deuk-agent.mdc", format: "mdc" },
|
|
108
|
+
{ id: "claude", detect: (cwd) => existsSync(join(cwd, "CLAUDE.md")) || existsSync(join(cwd, ".claude")), legacy: "CLAUDE.md", target: ".claude/rules/deuk-agent.md", format: "markdown" },
|
|
109
|
+
{ id: "copilot", detect: (cwd, tools = []) => tools.includes("copilot") || existsSync(join(cwd, ".github")), legacy: null, target: ".github/copilot-instructions.md", format: "markdown" },
|
|
110
|
+
{ id: "codex", detect: (cwd, tools = []) => tools.includes("codex") || existsSync(join(cwd, ".codex")), legacy: null, target: ".codex/AGENTS.md", format: "markdown" },
|
|
111
|
+
{ id: "windsurf", detect: (cwd) => existsSync(join(cwd, ".windsurf")), legacy: ".windsurfrules", target: ".windsurf/rules/deuk-agent.md", format: "markdown" },
|
|
112
|
+
{ id: "jetbrains", detect: (cwd) => existsSync(join(cwd, ".aiassistant")) || existsSync(join(cwd, ".idea")), legacy: null, target: ".aiassistant/rules/deuk-agent.md", format: "markdown" },
|
|
113
|
+
{ id: "antigravity", detect: (cwd, tools = []) => tools.includes("gemini") || existsSync(join(cwd, "GEMINI.md")) || existsSync(join(cwd, ".gemini")) || existsSync(join(cwd, ".mcp.json")), legacy: "GEMINI.md", target: "AGENTS.md", format: "markdown" }
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
export const DOC_LANGUAGE_CHOICES = [
|
|
117
|
+
{ label: "Auto (match system locale)", value: "auto" },
|
|
118
|
+
{ label: "Korean", value: "ko" },
|
|
119
|
+
{ label: "English", value: "en" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export function normalizeDocsLanguage(value) {
|
|
123
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
124
|
+
if (!normalized || normalized === "auto") return "auto";
|
|
125
|
+
if (normalized.startsWith("ko") || normalized === "kr" || normalized === "korean") return "ko";
|
|
126
|
+
if (normalized.startsWith("en") || normalized === "english") return "en";
|
|
127
|
+
return "auto";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function inferDocsLanguageFromEnv(env = process.env) {
|
|
131
|
+
const locale = String(env.LANG || env.LC_ALL || env.LC_MESSAGES || "").toLowerCase();
|
|
132
|
+
if (locale.includes("ko")) return "ko";
|
|
133
|
+
return "en";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function inferDocsLanguageFromText(text) {
|
|
137
|
+
const src = String(text || "");
|
|
138
|
+
const hangulCount = (src.match(/[\u3131-\u318e\uac00-\ud7a3]/g) || []).length;
|
|
139
|
+
if (hangulCount >= 2) return "ko";
|
|
140
|
+
|
|
141
|
+
const latinWords = src.match(/[A-Za-z][A-Za-z'-]*/g) || [];
|
|
142
|
+
if (latinWords.length >= 2) return "en";
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveDocsLanguage(value, env = process.env) {
|
|
148
|
+
const normalized = normalizeDocsLanguage(value);
|
|
149
|
+
if (normalized === "auto") return inferDocsLanguageFromEnv(env);
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function selectLocalizedTemplatePath(baseDir, templateName, docsLanguage = "en") {
|
|
154
|
+
const normalized = resolveDocsLanguage(docsLanguage);
|
|
155
|
+
const localizedName = `${templateName.replace(/\.md$/i, "")}.${normalized}.md`;
|
|
156
|
+
const localizedPath = join(baseDir, localizedName);
|
|
157
|
+
if (existsSync(localizedPath)) return localizedPath;
|
|
158
|
+
return join(baseDir, templateName);
|
|
159
|
+
}
|
|
160
|
+
|
|
34
161
|
export function loadInitConfig(cwd) {
|
|
35
162
|
const p = join(cwd, INIT_CONFIG_FILENAME);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
let target =
|
|
39
|
-
if (!target) return null;
|
|
163
|
+
if (!existsSync(p)) return null;
|
|
164
|
+
|
|
165
|
+
let target = p;
|
|
40
166
|
|
|
41
167
|
try {
|
|
42
168
|
const j = JSON.parse(readFileSync(target, "utf8"));
|
|
43
169
|
if (j.version !== INIT_CONFIG_VERSION) return null;
|
|
170
|
+
if (!j.docsLanguage) j.docsLanguage = "auto";
|
|
171
|
+
const workflowMode = normalizeWorkflowMode(j.workflowMode ?? j.approvalState);
|
|
172
|
+
j.workflowMode = workflowMode;
|
|
173
|
+
j.approvalState = workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending";
|
|
44
174
|
return j;
|
|
45
|
-
} catch {
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse config ${target}:`, err);
|
|
46
177
|
return null;
|
|
47
178
|
}
|
|
48
179
|
}
|
|
49
180
|
|
|
50
181
|
export function writeInitConfig(cwd, opts) {
|
|
51
182
|
const p = join(cwd, INIT_CONFIG_FILENAME);
|
|
183
|
+
const dir = dirname(p);
|
|
184
|
+
if (!existsSync(dir)) {
|
|
185
|
+
mkdirSync(dir, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
const existing = loadInitConfig(cwd) || {};
|
|
188
|
+
const workflowMode = normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approvalState ?? opts.approval);
|
|
52
189
|
const data = {
|
|
53
190
|
version: INIT_CONFIG_VERSION,
|
|
54
|
-
agentsMode: opts.agents
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
191
|
+
agentsMode: opts.agents ?? existing.agentsMode ?? "inject",
|
|
192
|
+
workflowMode,
|
|
193
|
+
approvalState: workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending",
|
|
194
|
+
stack: opts.stack ?? existing.stack,
|
|
195
|
+
agentTools: opts.agentTools ?? existing.agentTools,
|
|
196
|
+
docsLanguage: normalizeDocsLanguage(opts.docsLanguage ?? existing.docsLanguage ?? "auto"),
|
|
197
|
+
shareTickets: opts.shareTickets ?? existing.shareTickets ?? false,
|
|
198
|
+
remoteSync: opts.remoteSync ?? existing.remoteSync ?? false,
|
|
59
199
|
pipelineUrl: opts.pipelineUrl,
|
|
60
|
-
ignoreDirs: opts.ignoreDirs || DEFAULT_IGNORE_DIRS,
|
|
200
|
+
ignoreDirs: opts.ignoreDirs || existing.ignoreDirs || DEFAULT_IGNORE_DIRS,
|
|
61
201
|
updatedAt: new Date().toISOString(),
|
|
62
202
|
};
|
|
63
203
|
writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
|
|
64
204
|
}
|
|
65
205
|
|
|
206
|
+
export function normalizeWorkflowMode(value) {
|
|
207
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
208
|
+
if (!normalized) return WORKFLOW_MODE_PLAN;
|
|
209
|
+
if (["execute", "approved", "approval", "apply", "apply-changes"].includes(normalized)) {
|
|
210
|
+
return WORKFLOW_MODE_EXECUTE;
|
|
211
|
+
}
|
|
212
|
+
if (["plan", "pending", "review", "prepare", "prepare-only"].includes(normalized)) {
|
|
213
|
+
return WORKFLOW_MODE_PLAN;
|
|
214
|
+
}
|
|
215
|
+
return normalized === WORKFLOW_MODE_EXECUTE ? WORKFLOW_MODE_EXECUTE : WORKFLOW_MODE_PLAN;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function isWorkflowExecute(opts = {}, savedConfig = null) {
|
|
219
|
+
return normalizeWorkflowMode(
|
|
220
|
+
opts.workflowMode ??
|
|
221
|
+
opts.workflow ??
|
|
222
|
+
opts.approval ??
|
|
223
|
+
opts.approvalState ??
|
|
224
|
+
savedConfig?.workflowMode ??
|
|
225
|
+
savedConfig?.approvalState
|
|
226
|
+
) === WORKFLOW_MODE_EXECUTE;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolves the final workflow mode by checking opts, then saved config, with fallback.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveWorkflowMode(opts = {}, savedConfig = null) {
|
|
233
|
+
return normalizeWorkflowMode(
|
|
234
|
+
opts.workflowMode ??
|
|
235
|
+
opts.workflow ??
|
|
236
|
+
opts.approval ??
|
|
237
|
+
opts.approvalState ??
|
|
238
|
+
savedConfig?.workflowMode ??
|
|
239
|
+
savedConfig?.approvalState
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Strips dynamically appended rule modules from content.
|
|
245
|
+
*/
|
|
246
|
+
export function pruneRuleModules(content) {
|
|
247
|
+
const marker = "<!-- RULE MODULE: ";
|
|
248
|
+
const idx = content.indexOf(marker);
|
|
249
|
+
if (idx !== -1) {
|
|
250
|
+
return content.substring(0, idx).trimEnd();
|
|
251
|
+
}
|
|
252
|
+
return content.trimEnd();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Higher-order function to wrap interactive readline sessions.
|
|
257
|
+
*/
|
|
258
|
+
export async function withReadline(callback) {
|
|
259
|
+
if (!process.stdout.isTTY) {
|
|
260
|
+
throw new Error("Interactive terminal required.");
|
|
261
|
+
}
|
|
262
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
263
|
+
try {
|
|
264
|
+
return await callback(rl);
|
|
265
|
+
} finally {
|
|
266
|
+
rl.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
66
270
|
export function toPosixPath(p) {
|
|
67
271
|
return p.replace(/\\/g, "/");
|
|
68
272
|
}
|
|
@@ -74,7 +278,9 @@ export function toRepoRelativePath(cwd, absPath) {
|
|
|
74
278
|
export function toSlug(input) {
|
|
75
279
|
return String(input || "")
|
|
76
280
|
.toLowerCase()
|
|
77
|
-
.
|
|
281
|
+
.normalize("NFKD")
|
|
282
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
283
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
78
284
|
.replace(/^-+|-+$/g, "") || "ticket";
|
|
79
285
|
}
|
|
80
286
|
|
|
@@ -94,7 +300,7 @@ export function makeEntryId() {
|
|
|
94
300
|
|
|
95
301
|
export function findFileRecursively(dir, fileName) {
|
|
96
302
|
if (!existsSync(dir)) return null;
|
|
97
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
303
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
98
304
|
for (const entry of entries) {
|
|
99
305
|
const res = join(dir, entry.name);
|
|
100
306
|
if (entry.isDirectory()) {
|
|
@@ -109,20 +315,31 @@ export function findFileRecursively(dir, fileName) {
|
|
|
109
315
|
|
|
110
316
|
export function detectProjectFromBody(body) {
|
|
111
317
|
const content = String(body || "");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
318
|
+
const lines = content.split("\n");
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
const l = line.trim();
|
|
321
|
+
if (l.toLowerCase().startsWith("project:")) {
|
|
322
|
+
return l.split(":")[1].trim();
|
|
323
|
+
}
|
|
324
|
+
if (l.startsWith("# Project:") || l.startsWith("## Project:")) {
|
|
325
|
+
return l.split(":")[1].trim();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return "global";
|
|
120
329
|
}
|
|
121
330
|
|
|
122
331
|
export function deriveTopicFromBaseName(baseName) {
|
|
123
|
-
const raw = String(baseName || "").
|
|
124
|
-
|
|
125
|
-
|
|
332
|
+
const raw = String(baseName || "").split(".")[0];
|
|
333
|
+
// Remove trailing timestamp if present (e.g. topic-20260426-071208)
|
|
334
|
+
const parts = raw.split("-");
|
|
335
|
+
if (parts.length >= 3) {
|
|
336
|
+
const last = parts[parts.length - 1];
|
|
337
|
+
const prev = parts[parts.length - 2];
|
|
338
|
+
if (last.length === 6 && prev.length === 8) {
|
|
339
|
+
return parts.slice(0, -2).join("-");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return toSlug(raw);
|
|
126
343
|
}
|
|
127
344
|
|
|
128
345
|
export function resolveReferencedTicketPath(opts) {
|
|
@@ -141,38 +358,71 @@ export function inferRefTitleAndTopic(opts) {
|
|
|
141
358
|
let body = "";
|
|
142
359
|
try {
|
|
143
360
|
body = readFileSync(refAbs, "utf8");
|
|
144
|
-
} catch {
|
|
145
|
-
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read ref ${refAbs}:`, err);
|
|
363
|
+
return null;
|
|
146
364
|
}
|
|
147
365
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
366
|
+
const lines = body.split("\n");
|
|
367
|
+
let title = "";
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
const l = line.trim();
|
|
370
|
+
if (l.startsWith("## Task:")) {
|
|
371
|
+
title = l.replace("## Task:", "").trim();
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (l.startsWith("# ")) {
|
|
375
|
+
title = l.replace("# ", "").trim();
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
154
379
|
|
|
380
|
+
const base = basename(refAbs).split(".")[0];
|
|
381
|
+
const finalTitle = title || base;
|
|
155
382
|
return {
|
|
156
|
-
title: String(
|
|
157
|
-
topic,
|
|
383
|
+
title: String(finalTitle).trim(),
|
|
384
|
+
topic: toSlug(finalTitle),
|
|
158
385
|
};
|
|
159
386
|
}
|
|
160
387
|
|
|
161
388
|
export function parseFrontMatter(content) {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
try {
|
|
165
|
-
const meta = YAML.parse(match[1]);
|
|
166
|
-
return { meta: meta || {}, content: match[2] };
|
|
167
|
-
} catch (e) {
|
|
168
|
-
console.error("YAML Parse Error:", e);
|
|
389
|
+
const lines = content.split("\n");
|
|
390
|
+
if (lines.length < 3 || lines[0].trim() !== "---") {
|
|
169
391
|
return { meta: {}, content };
|
|
170
392
|
}
|
|
393
|
+
|
|
394
|
+
let endIdx = -1;
|
|
395
|
+
for (let i = 1; i < lines.length; i++) {
|
|
396
|
+
if (lines[i].trim() === "---") {
|
|
397
|
+
endIdx = i;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (endIdx === -1) return { meta: {}, content };
|
|
403
|
+
|
|
404
|
+
const metaStr = lines.slice(1, endIdx).join("\n");
|
|
405
|
+
const bodyStr = lines.slice(endIdx + 1).join("\n");
|
|
406
|
+
// Let YAML.parse throw if malformed (intentional behavior change to prevent data loss)
|
|
407
|
+
const meta = YAML.parse(metaStr);
|
|
408
|
+
return { meta: meta || {}, content: bodyStr };
|
|
171
409
|
}
|
|
172
410
|
|
|
173
411
|
export function stringifyFrontMatter(meta, content) {
|
|
174
|
-
const
|
|
175
|
-
|
|
412
|
+
const cleanMeta = { ...meta };
|
|
413
|
+
// Remove redundant or default fields to keep frontmatter slim
|
|
414
|
+
delete cleanMeta.topic;
|
|
415
|
+
if (cleanMeta.project === 'global') delete cleanMeta.project;
|
|
416
|
+
if (!cleanMeta.submodule) delete cleanMeta.submodule;
|
|
417
|
+
|
|
418
|
+
// Normalize date format if it looks like an ISO string
|
|
419
|
+
if (typeof cleanMeta.createdAt === 'string' && cleanMeta.createdAt.includes('T')) {
|
|
420
|
+
cleanMeta.createdAt = cleanMeta.createdAt.replace('T', ' ').split('.')[0];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const yamlStr = YAML.stringify(cleanMeta).trim();
|
|
424
|
+
// Double newline after frontmatter to ensure markdown rendering integrity
|
|
425
|
+
return `---\n${yamlStr}\n---\n\n\n${content.trim()}\n`;
|
|
176
426
|
}
|
|
177
427
|
|
|
178
428
|
/**
|
|
@@ -213,29 +463,101 @@ export async function checkUpdateNotifier() {
|
|
|
213
463
|
export const DEFAULT_IGNORE_DIRS = ["node_modules", ".git", ".deuk-agent", "tmp", "temp", ".tmp", ".cache"];
|
|
214
464
|
|
|
215
465
|
/**
|
|
216
|
-
*
|
|
466
|
+
* Resolves all potential ticket directories for a given path.
|
|
467
|
+
* Returns { primary, legacy: [] }
|
|
217
468
|
*/
|
|
218
|
-
export function
|
|
219
|
-
|
|
469
|
+
export function resolveTicketSystemPaths(cwd) {
|
|
470
|
+
return {
|
|
471
|
+
primary: join(cwd, AGENT_ROOT_DIR, TICKET_SUBDIR),
|
|
472
|
+
legacy: []
|
|
473
|
+
};
|
|
474
|
+
}
|
|
220
475
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Detects the closest active ticket directory by traversing upwards.
|
|
478
|
+
*/
|
|
479
|
+
export function detectConsumerTicketDir(startDir, opts = {}) {
|
|
480
|
+
let curr = resolve(startDir);
|
|
481
|
+
while (curr && curr !== dirname(curr)) {
|
|
482
|
+
const paths = resolveTicketSystemPaths(curr);
|
|
483
|
+
if (existsSync(paths.primary)) return paths.primary;
|
|
484
|
+
if (paths.legacy.length > 0) return paths.legacy[0];
|
|
485
|
+
curr = dirname(curr);
|
|
486
|
+
}
|
|
487
|
+
return opts.createIfMissing ? resolveTicketSystemPaths(startDir).primary : null;
|
|
488
|
+
}
|
|
224
489
|
|
|
225
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Unified workspace/submodule discovery.
|
|
492
|
+
*/
|
|
493
|
+
export function discoverAllWorkspaces(baseCwd, ignoreDirs = DEFAULT_IGNORE_DIRS, out = new Set()) {
|
|
494
|
+
if (!existsSync(baseCwd)) return Array.from(out);
|
|
495
|
+
|
|
496
|
+
const paths = resolveTicketSystemPaths(baseCwd);
|
|
497
|
+
if (existsSync(paths.primary) || paths.legacy.length > 0) {
|
|
226
498
|
out.add(baseCwd);
|
|
227
499
|
}
|
|
228
500
|
|
|
229
501
|
try {
|
|
230
|
-
const entries = readdirSync(baseCwd, { withFileTypes: true });
|
|
502
|
+
const entries = readdirSync(baseCwd, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
231
503
|
for (const ent of entries) {
|
|
232
504
|
if (!ent.isDirectory()) continue;
|
|
233
|
-
// Skip system or noisy directories based on ignoreDirs configuration
|
|
234
505
|
if (ignoreDirs.includes(ent.name) || ent.name.startsWith(".deuk-agent")) continue;
|
|
235
|
-
|
|
506
|
+
discoverAllWorkspaces(join(baseCwd, ent.name), ignoreDirs, out);
|
|
236
507
|
}
|
|
237
|
-
} catch {
|
|
238
|
-
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read directory ${baseCwd}:`, err);
|
|
239
510
|
}
|
|
240
511
|
return Array.from(out);
|
|
241
512
|
}
|
|
513
|
+
|
|
514
|
+
async function probeMcpUrl(url) {
|
|
515
|
+
const methods = ["HEAD", "GET"];
|
|
516
|
+
|
|
517
|
+
for (const method of methods) {
|
|
518
|
+
const controller = new AbortController();
|
|
519
|
+
const timeoutId = setTimeout(() => controller.abort(), 1000);
|
|
520
|
+
try {
|
|
521
|
+
const res = await fetch(url, { method, signal: controller.signal });
|
|
522
|
+
if (res.body?.cancel) await res.body.cancel().catch(() => {});
|
|
523
|
+
if (res.ok || res.status === 405) return true;
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] SSE ${method} ping failed for ${url}:`, err);
|
|
526
|
+
} finally {
|
|
527
|
+
clearTimeout(timeoutId);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Checks if the deuk-agent-context MCP server is active for the given workspace.
|
|
536
|
+
* Detects .mcp.json, .cursor/mcp.json, or .vscode/mcp.json and pings SSE servers if applicable.
|
|
537
|
+
*/
|
|
538
|
+
export async function isMcpActive(cwd) {
|
|
539
|
+
const mcpPaths = [
|
|
540
|
+
join(cwd, ".mcp.json"),
|
|
541
|
+
join(cwd, ".cursor", "mcp.json"),
|
|
542
|
+
join(cwd, ".vscode", "mcp.json")
|
|
543
|
+
];
|
|
544
|
+
for (const p of mcpPaths) {
|
|
545
|
+
if (existsSync(p)) {
|
|
546
|
+
try {
|
|
547
|
+
const config = JSON.parse(readFileSync(p, "utf8"));
|
|
548
|
+
const servers = config.mcpServers || config.servers || {};
|
|
549
|
+
const deuk = servers["deuk-agent-context"] || servers["deuk_agent_context"];
|
|
550
|
+
if (deuk) {
|
|
551
|
+
if (deuk.command) return true; // Stdio is managed by IDE
|
|
552
|
+
if (deuk.url) {
|
|
553
|
+
return await probeMcpUrl(deuk.url);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse MCP config ${p}: ${err.message}`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|