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.
Files changed (44) hide show
  1. package/CHANGELOG.ko.md +74 -0
  2. package/CHANGELOG.md +138 -316
  3. package/README.ko.md +134 -154
  4. package/README.md +121 -153
  5. package/package.json +29 -7
  6. package/scripts/cli-args.mjs +87 -3
  7. package/scripts/cli-init-commands.mjs +1382 -223
  8. package/scripts/cli-init-logic.mjs +28 -16
  9. package/scripts/cli-prompts.mjs +13 -4
  10. package/scripts/cli-rule-compiler.mjs +44 -34
  11. package/scripts/cli-skill-commands.mjs +172 -0
  12. package/scripts/cli-telemetry-commands.mjs +429 -0
  13. package/scripts/cli-ticket-commands.mjs +1934 -161
  14. package/scripts/cli-ticket-index.mjs +298 -0
  15. package/scripts/cli-ticket-migration.mjs +320 -0
  16. package/scripts/cli-ticket-parser.mjs +207 -0
  17. package/scripts/cli-utils.mjs +381 -59
  18. package/scripts/cli.mjs +99 -19
  19. package/scripts/lint-md.mjs +247 -0
  20. package/scripts/lint-rules.mjs +143 -0
  21. package/scripts/merge-logic.mjs +13 -306
  22. package/scripts/plan-parser.mjs +53 -0
  23. package/templates/MODULE_RULE_TEMPLATE.md +11 -0
  24. package/templates/PROJECT_RULE.md +47 -0
  25. package/templates/TICKET_TEMPLATE.ko.md +21 -0
  26. package/templates/TICKET_TEMPLATE.md +21 -0
  27. package/templates/rules.d/deukcontext-mcp.md +31 -0
  28. package/templates/rules.d/platform-coexistence.md +29 -0
  29. package/templates/skills/context-recall/SKILL.md +25 -0
  30. package/templates/skills/generated-file-guard/SKILL.md +25 -0
  31. package/templates/skills/safe-refactor/SKILL.md +25 -0
  32. package/bundle/.cursorrules +0 -11
  33. package/bundle/AGENTS.md +0 -146
  34. package/bundle/gemini.md +0 -26
  35. package/bundle/rules/delivery-and-parallel-work.mdc +0 -26
  36. package/bundle/rules/git-commit.mdc +0 -24
  37. package/bundle/rules/multi-ai-workflow.mdc +0 -104
  38. package/bundle/rules.d/core-workflow.md +0 -48
  39. package/bundle/rules.d/deukrag-mcp.md +0 -37
  40. package/bundle/templates/MODULE_RULE_TEMPLATE.md +0 -24
  41. package/bundle/templates/TICKET_TEMPLATE.md +0 -58
  42. package/scripts/cli-ticket-logic.mjs +0 -568
  43. package/scripts/sync-bundle.mjs +0 -77
  44. package/scripts/sync-oss.mjs +0 -126
@@ -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 TICKET_LIST_TEMPLATE_FILENAME = "TICKET_LIST.template.md";
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
- const legacyP = join(cwd, LEGACY_INIT_CONFIG_FILENAME);
37
-
38
- let target = existsSync(p) ? p : (existsSync(legacyP) ? legacyP : null);
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 || "inject",
55
- stack: opts.stack,
56
- agentTools: opts.agentTools,
57
- shareTickets: !!opts.shareTickets,
58
- remoteSync: !!opts.remoteSync,
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
- .replace(/[^\p{L}\p{N}]+/gu, "-")
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 metaMatch = content.match(/^project:\s*(.+)$/mi);
113
- if (metaMatch) return metaMatch[1].trim();
114
-
115
- const headerMatch = content.match(/^##?\s+Project:\s*(.+)$/mi);
116
- if (headerMatch) return headerMatch[1].trim();
117
-
118
- const legacyMatch = content.match(/\b(YourProject)\b/i);
119
- return legacyMatch ? legacyMatch[1] : "global";
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 || "").replace(/\.md$/i, "");
124
- const topic = raw.replace(/-\d{8}-\d{6}$/i, "");
125
- return toSlug(topic || raw || "ticket");
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
- body = "";
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 taskTitleMatch = body.match(/^##\s+Task:\s*(.+)$/m);
149
- const headingMatch = body.match(/^#\s+(.+)$/m);
150
- const base = basename(refAbs).replace(/\.[^.]+$/, "");
151
-
152
- const title = (taskTitleMatch && taskTitleMatch[1]) || (headingMatch && headingMatch[1]) || base;
153
- const topic = toSlug(title || base);
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(title || base).trim(),
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 match = content.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n?([\s\S]*)$/);
163
- if (!match) return { meta: {}, content };
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 yamlStr = YAML.stringify(meta).trim();
175
- return `---\n${yamlStr}\n---\n\n${content.trim()}\n`;
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
- * Recursively finds all directories containing deuk-agent ticket structures.
466
+ * Resolves all potential ticket directories for a given path.
467
+ * Returns { primary, legacy: [] }
217
468
  */
218
- export function discoverAllSubmodules(baseCwd, ignoreDirs = DEFAULT_IGNORE_DIRS, out = new Set()) {
219
- if (!existsSync(baseCwd)) return Array.from(out);
469
+ export function resolveTicketSystemPaths(cwd) {
470
+ return {
471
+ primary: join(cwd, AGENT_ROOT_DIR, TICKET_SUBDIR),
472
+ legacy: []
473
+ };
474
+ }
220
475
 
221
- const hasLegacy1 = existsSync(join(baseCwd, ".deuk-agent-ticket"));
222
- const hasLegacy2 = existsSync(join(baseCwd, ".deuk-agent-tickets"));
223
- const hasNew = existsSync(join(baseCwd, AGENT_ROOT_DIR, TICKET_SUBDIR));
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
- if (hasLegacy1 || hasLegacy2 || hasNew) {
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
- discoverAllSubmodules(join(baseCwd, ent.name), ignoreDirs, out);
506
+ discoverAllWorkspaces(join(baseCwd, ent.name), ignoreDirs, out);
236
507
  }
237
- } catch {
238
- // Ignore permission errors on specific subfolders
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
+ }