deuk-agent-flow 4.2.7 → 5.0.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 (80) hide show
  1. package/CHANGELOG.ko.md +259 -0
  2. package/CHANGELOG.md +769 -124
  3. package/LICENSE +0 -0
  4. package/README.ko.md +97 -17
  5. package/README.md +108 -25
  6. package/bin/deuk-agent-flow.js +11 -13
  7. package/bin/deuk-agent-rule.js +1 -1
  8. package/bundled/README.md +3 -0
  9. package/bundled/deuk-agent-flow.vsix +0 -0
  10. package/core-rules/AGENTS.md +30 -120
  11. package/docs/architecture.ko.md +155 -2
  12. package/docs/architecture.md +155 -2
  13. package/docs/assets/agentflow-panel-skills.png +0 -0
  14. package/docs/assets/agentflow-panel.png +0 -0
  15. package/docs/how-it-works.ko.md +109 -52
  16. package/docs/how-it-works.md +128 -71
  17. package/docs/principles.ko.md +68 -68
  18. package/docs/principles.md +68 -68
  19. package/docs/usage-guide.ko.md +251 -212
  20. package/package.json +42 -45
  21. package/scripts/bundle-vscode-vsix.ts +67 -0
  22. package/scripts/{cli-args.mjs → cli-args.ts} +49 -12
  23. package/scripts/cli-init-commands.ts +99 -0
  24. package/scripts/cli-init-logic.ts +46 -0
  25. package/scripts/{cli-prompts.mjs → cli-prompts.ts} +19 -34
  26. package/scripts/{cli-rule-compiler.mjs → cli-rule-compiler.ts} +128 -112
  27. package/scripts/cli-skill-commands.ts +707 -0
  28. package/scripts/{cli-telemetry-commands.mjs → cli-telemetry-commands.ts} +25 -22
  29. package/scripts/cli-ticket-command-shared.ts +4 -0
  30. package/scripts/cli-ticket-commands.ts +3723 -0
  31. package/scripts/cli-ticket-index.ts +283 -0
  32. package/scripts/{cli-ticket-migration.mjs → cli-ticket-migration.ts} +44 -68
  33. package/scripts/cli-ticket-parser.ts +100 -0
  34. package/scripts/{cli-usage-commands.mjs → cli-usage-commands.ts} +325 -326
  35. package/scripts/cli-utils.ts +1560 -0
  36. package/scripts/cli.ts +695 -0
  37. package/scripts/{lint-md.mjs → lint-md.ts} +32 -42
  38. package/scripts/lint-rules.ts +203 -0
  39. package/scripts/{merge-logic.mjs → merge-logic.ts} +44 -44
  40. package/scripts/{plan-parser.mjs → plan-parser.ts} +53 -53
  41. package/templates/MODULE_RULE_TEMPLATE.md +11 -11
  42. package/templates/PROJECT_RULE.md +46 -47
  43. package/templates/TICKET_TEMPLATE.ko.md +48 -44
  44. package/templates/TICKET_TEMPLATE.md +48 -44
  45. package/templates/project-memory.md +19 -0
  46. package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +25 -25
  47. package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -27
  48. package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -26
  49. package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -30
  50. package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -26
  51. package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -15
  52. package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -34
  53. package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -32
  54. package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -33
  55. package/templates/rules.d/deukcontext-mcp.md +31 -31
  56. package/templates/rules.d/platform-coexistence.md +29 -29
  57. package/templates/skills/context-recall/SKILL.md +3 -1
  58. package/templates/skills/doc-sync/SKILL.md +111 -0
  59. package/templates/skills/generated-file-guard/SKILL.md +3 -1
  60. package/templates/skills/persona-maid/SKILL.md +65 -0
  61. package/templates/skills/project-pilot/SKILL.md +14 -63
  62. package/templates/skills/safe-refactor/SKILL.md +3 -1
  63. package/templates/skills/ticket-status-surface/SKILL.md +17 -0
  64. package/NOTICE.md +0 -19
  65. package/core-rules/GEMINI.md +0 -7
  66. package/docs/npm-publish-guide.ko.md +0 -70
  67. package/scripts/cli-init-commands.mjs +0 -1759
  68. package/scripts/cli-init-logic.mjs +0 -64
  69. package/scripts/cli-skill-commands.mjs +0 -212
  70. package/scripts/cli-ticket-command-shared.mjs +0 -60
  71. package/scripts/cli-ticket-commands.mjs +0 -2474
  72. package/scripts/cli-ticket-index.mjs +0 -322
  73. package/scripts/cli-ticket-parser.mjs +0 -210
  74. package/scripts/cli-utils.mjs +0 -602
  75. package/scripts/cli.mjs +0 -256
  76. package/scripts/lint-rules.mjs +0 -197
  77. package/scripts/publish-dual-npm.mjs +0 -141
  78. package/scripts/smoke-npm-docker.mjs +0 -102
  79. package/scripts/smoke-npm-local.mjs +0 -110
  80. package/scripts/update-download-badge.mjs +0 -103
@@ -0,0 +1,1560 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "fs";
2
+ import { basename, dirname, relative, resolve, sep, win32, posix } from "path";
3
+ import { homedir } from "os";
4
+ import { pathToFileURL, fileURLToPath } from "url";
5
+ import { createInterface } from "readline";
6
+ import { createHash } from "crypto";
7
+ import YAML from "yaml";
8
+
9
+ // Loose options bag — all CLI functions accept this so callers don't need to list every key.
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ export type CliOpts = Record<string, any>;
12
+
13
+ // #622: cli-ticket-home.mjs registers itself here at import time to avoid a static
14
+ // circular import (home imports utils). detectConsumerTicketDir uses it when present.
15
+ let _ticketHomeModule = null;
16
+ export function registerTicketHomeModule(mod) { _ticketHomeModule = mod; }
17
+ function ticketHomeModule() {
18
+ if (!_ticketHomeModule) throw new Error("ticket home module not registered");
19
+ return _ticketHomeModule;
20
+ }
21
+
22
+ /** Converts an absolute path to a clickable file:/// URI */
23
+ export function toFileUri(absPath) {
24
+ return pathToFileURL(absPath).href;
25
+ }
26
+
27
+ /** Converts an absolute path to a vscode://file/ URI (clickable in VSCode terminal) */
28
+ export function toVscodeFileUri(absPath) {
29
+ const posixPath = absPath.replace(/\\/g, "/");
30
+ const normalized = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
31
+ return `vscode://file${normalized}`;
32
+ }
33
+
34
+ /** writeFileSync wrapper that always normalizes to LF before writing */
35
+ export function writeFileLF(path: string, content: string, encoding: BufferEncoding = "utf8") {
36
+ const normalized = String(content).replace(/\r\n/g, "\n");
37
+ writeFileSync(path, normalized, encoding);
38
+ }
39
+
40
+ // #713: The deuk home root fragment (the directory name under the user home / workspace).
41
+ // Holds deuk-family-wide assets (tickets/registry/...). VALUE switched .deuk-agent→.deuk
42
+ // (#710 renamed the symbol AGENT_ROOT_DIR→DEUK_ROOT_DIR; #713 flips the value). The legacy
43
+ // ~/.deuk-agent directory is migrated to ~/.deuk by init auto-migration (cli-init-migrate).
44
+ // The AGENT_ROOT_DIR alias is removed — all .ts call sites use DEUK_ROOT_DIR.
45
+ export const DEUK_ROOT_DIR = ".deuk";
46
+ export const WORKSPACE_MARKER_FILE = ".deuk-workspace-id";
47
+ export const TICKET_SUBDIR = "tickets";
48
+ export const RULES_SUBDIR = "rules";
49
+ export const WORKFLOW_MODE_PLAN = "plan";
50
+ export const WORKFLOW_MODE_EXECUTE = "execute";
51
+
52
+ // #710: relative path under the deuk root. The SINGLE place that joins DEUK_ROOT_DIR with
53
+ // sub-segments — callers pass fragments only (TICKET_SUBDIR, ...) and never re-assemble the
54
+ // root themselves. Replaces the assembled constants (TICKET_DIR_NAME/PLAN_LINKS_DIR/...) that
55
+ // baked the root into SSOT and forced double-assembly across call sites (708 design).
56
+ export function deukRel(...segments: string[]): string {
57
+ return makePath(DEUK_ROOT_DIR, ...segments);
58
+ }
59
+
60
+ export const TICKET_INDEX_FILENAME = "INDEX.json";
61
+ export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
62
+ export const DOCS_SUBDIR = "docs";
63
+ export const PLANS_SUBDIR = "plan";
64
+
65
+ // #713: SINGLE source of truth for everything about the OLD (.deuk-agent-era) layout.
66
+ // All legacy-cleanup / scan-exclude / migration code reads from this one object instead of
67
+ // scattered consts — add a future legacy name here once and every site follows. These are the
68
+ // OLD names (fixed); they must NEVER follow DEUK_ROOT_DIR (the current ".deuk"), or cleanup
69
+ // code would start treating the new dir as legacy and delete it. `dirs` = old directory names
70
+ // that purge/scan/ignore logic must recognize (most-specific first).
71
+ export const LEGACY_DEUK = {
72
+ rootDir: ".deuk-agent",
73
+ templateDir: ".deuk-agent-templates",
74
+ ticketDir: ".deuk-agent-ticket",
75
+ ticketDirPlural: ".deuk-agent-tickets",
76
+ configFile: ".deuk-agent-rule.config.json",
77
+ ticketGroupRoot: "ticket",
78
+ dirs: [
79
+ ".deuk-agent-templates",
80
+ ".deuk-agent-tickets",
81
+ ".deuk-agent-ticket",
82
+ ".deuk-agent",
83
+ ],
84
+ } as const;
85
+
86
+ export const ARCHIVE_YEAR_MONTH_RE = /^\d{4}-\d{2}$/;
87
+ export const ARCHIVE_DAY_RE = /^\d{2}$/;
88
+
89
+ const LEGACY_TICKET_GROUPS = new Set<string>([
90
+ LEGACY_DEUK.ticketDir,
91
+ LEGACY_DEUK.ticketDirPlural,
92
+ "ticket",
93
+ "tickets"
94
+ ]);
95
+
96
+ /**
97
+ * Computes the canonical repository-relative path for a ticket based on its state.
98
+ */
99
+ export function computeTicketPath(entry) {
100
+ // #080: paths are relative to the TICKET ROOT (the home store key dir,
101
+ // ~/.deuk-agent/tickets/{key}/). The legacy `.deuk-agent/tickets/` prefix dates
102
+ // from in-workspace storage; keeping it nested every self-heal/create one level
103
+ // deeper inside the store ({key}/.deuk-agent/tickets/main/...).
104
+ const isArchived = entry.status === "archived";
105
+ const group = normalizeTicketGroup(entry.group, "sub");
106
+ const fileStem = entry.fileName
107
+ ? String(entry.fileName).replace(/\.md$/i, "")
108
+ : entry.id;
109
+
110
+ if (!isArchived && group === TICKET_SUBDIR) {
111
+ return `${fileStem}.md`;
112
+ }
113
+
114
+ if (isArchived && entry.archiveYearMonth) {
115
+ return ["archive", group, entry.archiveYearMonth, `${fileStem}.md`].join("/");
116
+ }
117
+
118
+ const parts = [
119
+ isArchived ? "archive" : null,
120
+ group,
121
+ `${fileStem}.md`
122
+ ].filter(Boolean);
123
+ return parts.join("/");
124
+ }
125
+
126
+ export function normalizeTicketGroup(rawGroup, fallback = "sub") {
127
+ const value = String(rawGroup || "").trim();
128
+ if (!value) return fallback;
129
+ if (value.includes("/") || value.startsWith("TICKET-") && value.includes(".md")) return fallback;
130
+ if (value.endsWith(".md")) return fallback;
131
+ if (value.endsWith(".markdown")) return fallback;
132
+ if (LEGACY_TICKET_GROUPS.has(value)) return fallback;
133
+ return value;
134
+ }
135
+
136
+ export const CONFIG_FILENAME = "config.json";
137
+ export const GLOBAL_CONFIG_DIR_NAME = "deuk-agent-flow";
138
+ export const GLOBAL_CONFIG_FILENAME = "config.json";
139
+ export const INIT_CONFIG_VERSION = 1;
140
+
141
+ export const WORKSPACE_KINDS = [
142
+ { label: "Product planning / strategy / specs", value: "planning" },
143
+ { label: "Software engineering / coding", value: "coding" },
144
+ { label: "Systems engineering / operations", value: "systems" },
145
+ { label: "Research / analysis / knowledge work", value: "research" },
146
+ { label: "Mixed team workspace", value: "mixed" },
147
+ { label: "Other / decide later", value: "other" },
148
+ ];
149
+
150
+ export const STACKS = [
151
+ { label: "Not primarily a code repo", value: "none" },
152
+ { label: "Web / product app", value: "web" },
153
+ { label: "Backend / service / API", value: "backend" },
154
+ { label: "Unity / game / simulation", value: "unity" },
155
+ { label: "Data / ML / analysis", value: "data" },
156
+ { label: "Infrastructure / platform / SRE", value: "infra" },
157
+ { label: "Hybrid / multiple stacks", value: "hybrid" },
158
+ { label: "Other / skip", value: "other" },
159
+ ];
160
+
161
+ export const AGENT_TOOLS = [
162
+ { label: "OpenAI Codex", value: "codex" },
163
+ { label: "GitHub Copilot", value: "copilot" },
164
+ { label: "Claude Code", value: "claude" },
165
+ { label: "Cursor", value: "cursor" },
166
+ { label: "Gemini / Antigravity", value: "gemini" },
167
+ { label: "Windsurf", value: "windsurf" },
168
+ { label: "JetBrains AI Assistant", value: "jetbrains" },
169
+ ];
170
+
171
+ export const SPOKE_REGISTRY = [
172
+ { id: "cursor", detect: (cwd) => existsSync(makePath(cwd, ".cursor")), legacy: ".cursorrules", target: ".cursor/rules/deuk-agent.mdc", format: "mdc" },
173
+ { id: "claude", detect: (cwd) => existsSync(makePath(cwd, "CLAUDE.md")) || existsSync(makePath(cwd, ".claude")), legacy: "CLAUDE.md", target: ".claude/rules/deuk-agent.md", format: "markdown" },
174
+ { id: "copilot", detect: (cwd, tools = []) => tools.includes("copilot") || existsSync(makePath(cwd, ".github")), legacy: null, target: ".github/copilot-instructions.md", format: "markdown" },
175
+ { id: "codex", detect: (cwd, tools = []) => tools.includes("codex") || existsSync(makePath(cwd, ".codex")), legacy: null, target: ".codex/AGENTS.md", format: "markdown" },
176
+ { id: "windsurf", detect: (cwd) => existsSync(makePath(cwd, ".windsurf")), legacy: ".windsurfrules", target: ".windsurf/rules/deuk-agent.md", format: "markdown" },
177
+ { id: "jetbrains", detect: (cwd) => existsSync(makePath(cwd, ".aiassistant")) || existsSync(makePath(cwd, ".idea")), legacy: null, target: ".aiassistant/rules/deuk-agent.md", format: "markdown" },
178
+ { id: "antigravity", detect: (cwd, tools = []) => tools.includes("gemini") || existsSync(makePath(cwd, "GEMINI.md")) || existsSync(makePath(cwd, ".gemini")) || existsSync(makePath(cwd, ".mcp.json")), legacy: "AGENTS.md", target: "GEMINI.md", format: "markdown" }
179
+ ];
180
+
181
+ export const DOC_LANGUAGE_CHOICES = [
182
+ { label: "Auto (match system locale)", value: "auto" },
183
+ { label: "Korean", value: "ko" },
184
+ { label: "English", value: "en" },
185
+ ];
186
+
187
+ export function normalizeDocsLanguage(value) {
188
+ const normalized = String(value || "").trim().toLowerCase();
189
+ if (!normalized || normalized === "auto") return "auto";
190
+ if (normalized.startsWith("ko") || normalized === "kr" || normalized === "korean") return "ko";
191
+ if (normalized.startsWith("en") || normalized === "english") return "en";
192
+ return "auto";
193
+ }
194
+
195
+ export function inferDocsLanguageFromEnv(env = process.env) {
196
+ // 1) POSIX 환경변수 우선(Linux/macOS, 또는 사용자가 Windows에서 명시 설정한 경우).
197
+ const posix = String(env.LANG || env.LC_ALL || env.LC_MESSAGES || "").toLowerCase();
198
+ if (posix) return posix.includes("ko") ? "ko" : "en";
199
+ // 2) Windows 등 LANG 부재 환경: Node가 OS 로케일을 반영하는 Intl로 폴백.
200
+ try {
201
+ const osLocale = String(Intl.DateTimeFormat().resolvedOptions().locale || "").toLowerCase();
202
+ if (osLocale.includes("ko")) return "ko";
203
+ if (osLocale) return "en";
204
+ } catch {
205
+ // Intl 미지원(초경량 런타임) 시 최종 폴백으로.
206
+ }
207
+ // 3) 최종 폴백.
208
+ return "en";
209
+ }
210
+
211
+ export function inferDocsLanguageFromText(text) {
212
+ const src = String(text || "");
213
+ const hangulCount = (src.match(/[\u3131-\u318e\uac00-\ud7a3]/g) || []).length;
214
+ if (hangulCount >= 2) return "ko";
215
+
216
+ const latinWords = src.match(/[A-Za-z][A-Za-z'-]*/g) || [];
217
+ if (latinWords.length >= 2) return "en";
218
+
219
+ return null;
220
+ }
221
+
222
+ export function resolveDocsLanguage(value, env = process.env) {
223
+ const normalized = normalizeDocsLanguage(value);
224
+ if (normalized === "auto") return inferDocsLanguageFromEnv(env);
225
+ return normalized;
226
+ }
227
+
228
+ export function selectLocalizedTemplatePath(baseDir, templateName, docsLanguage = "en") {
229
+ const normalized = resolveDocsLanguage(docsLanguage);
230
+ const localizedName = `${templateName.replace(/\.md$/i, "")}.${normalized}.md`;
231
+ const localizedPath = makePath(baseDir, localizedName);
232
+ if (existsSync(localizedPath)) return localizedPath;
233
+ return makePath(baseDir, templateName);
234
+ }
235
+
236
+ export function loadInitConfig(cwd, opts: CliOpts = {}) {
237
+ const targets = [
238
+ getUserInitConfigPath()
239
+ ].filter(Boolean);
240
+
241
+ for (const target of targets) {
242
+ if (!existsSync(target)) continue;
243
+ try {
244
+ const j = JSON.parse(readFileSync(target, "utf8"));
245
+ if (j.version !== INIT_CONFIG_VERSION) continue;
246
+ if (!j.docsLanguage) j.docsLanguage = "auto";
247
+ const workflowMode = normalizeWorkflowMode(j.workflowMode ?? j.approvalState);
248
+ j.workflowMode = workflowMode;
249
+ j.approvalState = workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending";
250
+ if (j.scmIgnoreDeukAgent === undefined) j.scmIgnoreDeukAgent = !Boolean(j.shareTickets);
251
+ j.configPath = target;
252
+ j.configScope = "user";
253
+ return j;
254
+ } catch (err) {
255
+ if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse config ${target}:`, err);
256
+ }
257
+ }
258
+ return null;
259
+ }
260
+
261
+ export function writeInitConfig(cwd, opts) {
262
+ const p = getUserInitConfigPath(opts);
263
+ const dir = dirname(p);
264
+ if (!existsSync(dir)) {
265
+ mkdirSync(dir, { recursive: true });
266
+ }
267
+ const existing = loadInitConfig(cwd) || {};
268
+ const workflowMode = normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approvalState ?? opts.approval);
269
+ const data = {
270
+ version: INIT_CONFIG_VERSION,
271
+ workflowMode,
272
+ approvalState: workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending",
273
+ workspaceKind: opts.workspaceKind ?? opts.kind ?? existing.workspaceKind ?? existing.kind,
274
+ stack: opts.stack ?? existing.stack,
275
+ kind: opts.workspaceKind ?? opts.kind ?? existing.workspaceKind ?? existing.kind,
276
+ agentTools: opts.agentTools ?? existing.agentTools,
277
+ docsLanguage: normalizeDocsLanguage(opts.docsLanguage ?? existing.docsLanguage ?? "auto"),
278
+ scmIgnoreDeukAgent: opts.scmIgnoreDeukAgent ?? existing.scmIgnoreDeukAgent ?? !Boolean(opts.shareTickets ?? existing.shareTickets ?? false),
279
+ shareTickets: opts.shareTickets ?? existing.shareTickets ?? false,
280
+ contextMcp: opts.contextMcp ?? existing.contextMcp ?? "skip",
281
+ remoteSync: opts.remoteSync ?? existing.remoteSync ?? false,
282
+ pipelineUrl: opts.pipelineUrl,
283
+ ignoreDirs: opts.ignoreDirs || existing.ignoreDirs || DEFAULT_IGNORE_DIRS,
284
+ updatedAt: new Date().toISOString(),
285
+ };
286
+ writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
287
+ }
288
+
289
+ // Single source of truth for the current user's home directory. Every home-derived
290
+ // path (global agent pointers, Claude settings hook, workspace registry) must route
291
+ // through here so platform/env home policy lives in one place instead of being
292
+ // sprawled across call sites with divergent fallbacks.
293
+ //
294
+ // process.env.HOME is intentionally NOT consulted: the ~/bin/node wrapper delegates
295
+ // to the Windows node.exe, so homedir() already resolves to the shared Windows home
296
+ // (C:\Users\joy) under both Windows and WSL. Honoring env.HOME would make a native
297
+ // WSL node pick /home/joy and break that shared-home contract. Tests inject opts.homeDir.
298
+ export function resolveUserHome(opts: CliOpts = {}) {
299
+ return opts.homeDir || homedir();
300
+ }
301
+
302
+ // The project's single path-construction primitive. Joins segments and normalizes
303
+ // the result to the target platform's convention (canonical form = platform-native:
304
+ // win32 -> "\\", posix -> "/"), regardless of how the inputs were stored ??mixed or
305
+ // foreign separators are accepted. platform defaults to the running platform; pass
306
+ // opts.platform to build a path for another platform deterministically (used by
307
+ // tests so they compare via this same function and stay platform-agnostic).
308
+ export function makePath(...args) {
309
+ // Variadic drop-in for path.join: makePath(a, b, c). Also accepts a single
310
+ // segments array and/or a trailing { platform } options object:
311
+ // makePath([a, b], { platform: "win32" }) | makePath(a, b, { platform })
312
+ let opts: CliOpts = {};
313
+ if (args.length > 1 && args[args.length - 1] && typeof args[args.length - 1] === "object" && !Array.isArray(args[args.length - 1])) {
314
+ opts = args.pop();
315
+ }
316
+ const segments = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
317
+ const platform = opts.platform || process.platform;
318
+ const mod = platform === "win32" ? win32 : posix;
319
+ // Swap the *foreign* separator to the target's (no collapsing ??keeps leading
320
+ // "\\\\" UNC prefixes like \\wsl.localhost\... intact); the join then normalizes.
321
+ const foreign = platform === "win32" ? /\//g : /\\/g;
322
+ const parts = segments
323
+ .map(part => String(part ?? ""))
324
+ .filter(part => part.length > 0)
325
+ .map(part => part.replace(foreign, mod.sep));
326
+ return mod.join(...parts);
327
+ }
328
+
329
+ // #709: Single source of truth for the package root. Historically 10 call sites computed
330
+ // this five different ways (dirname(dirname(import.meta.url)), makePath(__dirname, ".."),
331
+ // resolve(dirname(fileURLToPath()), ".."), ...). Those bake in a fixed directory depth, so
332
+ // the tsc build output (scripts/out/scripts/) shifted the depth and broke docs/ resolution
333
+ // ("CLI surface document not found: scripts/out/docs/..."). This walks UP from the caller's
334
+ // file looking for the package.json marker, so it returns the SAME root whether the code runs
335
+ // from source (scripts/) or build output (scripts/out/scripts/) — location-independent.
336
+ let _packageRootCache: string | null = null;
337
+ export function resolvePackageRoot(opts: CliOpts = {}): string {
338
+ if (opts.packageRoot) return opts.packageRoot;
339
+ if (!opts.noCache && _packageRootCache) return _packageRootCache;
340
+ const startFile = opts.fromUrl ? fileURLToPath(opts.fromUrl) : fileURLToPath(import.meta.url);
341
+ let dir = dirname(startFile);
342
+ // Walk up until a package.json is found; stop at filesystem root.
343
+ while (true) {
344
+ if (existsSync(makePath(dir, "package.json"))) break;
345
+ const parent = dirname(dir);
346
+ if (parent === dir) break; // reached root without a marker — fall back to original dir
347
+ dir = parent;
348
+ }
349
+ if (!opts.noCache) _packageRootCache = dir;
350
+ return dir;
351
+ }
352
+
353
+ // #632: OS-agnostic basename. path.basename uses the CURRENT runtime's separator, so
354
+ // under a POSIX node (WSL / Git Bash) it cannot split a Windows path — basename(
355
+ // "D:\\workspace\\DeukPack") wrongly returns "workspaceDeukPack". normalizeRegistryPath
356
+ // emits Windows-style paths regardless of runtime, so any code deriving a name from one
357
+ // (e.g. computeTicketKey) must split on BOTH separators. Mirrors makePath's cross-OS intent.
358
+ export function basenameAnyOS(value) {
359
+ return String(value ?? "").split(/[\\/]/).filter(Boolean).pop() || "";
360
+ }
361
+
362
+ export function getUserConfigDir(opts: CliOpts = {}) {
363
+ const platform = opts.platform || process.platform;
364
+ const env = opts.env || process.env;
365
+ const homeDir = resolveUserHome(opts);
366
+
367
+ if (platform === "win32") {
368
+ const base = env.APPDATA || makePath([homeDir, "AppData", "Roaming"], { platform });
369
+ return makePath([base, GLOBAL_CONFIG_DIR_NAME], { platform });
370
+ }
371
+
372
+ const base = env.XDG_CONFIG_HOME || makePath([homeDir, ".config"], { platform });
373
+ return makePath([base, GLOBAL_CONFIG_DIR_NAME], { platform });
374
+ }
375
+
376
+ export function getUserInitConfigPath(opts: CliOpts = {}) {
377
+ return makePath([getUserConfigDir(opts), GLOBAL_CONFIG_FILENAME], opts);
378
+ }
379
+
380
+ // Global, workspace-independent directory for user-forked/custom skills.
381
+ // Lives alongside the global config so personalized skills follow the user
382
+ // across every workspace (win: %APPDATA%\deuk-agent-flow\skills,
383
+ // posix: $XDG_CONFIG_HOME|~/.config/deuk-agent-flow/skills).
384
+ export function getUserSkillsDir(opts: CliOpts = {}) {
385
+ return makePath([resolveUserHome(opts), DEUK_ROOT_DIR, "skills"], opts);
386
+ }
387
+
388
+ // Global, workspace-independent path for skill install state (the `installed`
389
+ // array). Lives next to the global skills dir so a skill installed once shows
390
+ // the same state across every editor/workspace. Exposure stays per-workspace
391
+ // because it is physically injected into agent rule files.
392
+ export function getUserSkillsConfigPath(opts: CliOpts = {}) {
393
+ return makePath([resolveUserHome(opts), DEUK_ROOT_DIR, "skills.json"], opts);
394
+ }
395
+
396
+ export function getWorkspaceInitConfigPath(cwd) {
397
+ return makePath(cwd, deukRel(CONFIG_FILENAME));
398
+ }
399
+
400
+ export function migrateWorkspaceInitConfigToUserConfig(cwd, opts: CliOpts = {}) {
401
+ const workspaceConfig = getWorkspaceInitConfigPath(cwd);
402
+ if (!existsSync(workspaceConfig)) return false;
403
+
404
+ const dryRun = Boolean(opts.dryRun);
405
+ const silent = Boolean(opts.silent);
406
+
407
+ if (!dryRun) unlinkSync(workspaceConfig);
408
+ if (!silent) {
409
+ const relWorkspaceConfig = toRepoRelativePath(cwd, workspaceConfig);
410
+ console.log(`[MIGRATE] ${dryRun ? "Would remove" : "Removed"} legacy workspace config: ${relWorkspaceConfig}`);
411
+ }
412
+ return dryRun ? "dry-run-remove-workspace-config" : "removed-workspace-config";
413
+ }
414
+
415
+ export function normalizeWorkflowMode(value) {
416
+ const normalized = String(value || "").trim().toLowerCase();
417
+ if (!normalized) return WORKFLOW_MODE_PLAN;
418
+ if (["execute", "approved", "approval", "apply", "apply-changes"].includes(normalized)) {
419
+ return WORKFLOW_MODE_EXECUTE;
420
+ }
421
+ if (["plan", "pending", "review", "prepare", "prepare-only"].includes(normalized)) {
422
+ return WORKFLOW_MODE_PLAN;
423
+ }
424
+ return normalized === WORKFLOW_MODE_EXECUTE ? WORKFLOW_MODE_EXECUTE : WORKFLOW_MODE_PLAN;
425
+ }
426
+
427
+ export function isWorkflowExecute(opts: CliOpts = {}, savedConfig = null) {
428
+ return normalizeWorkflowMode(
429
+ opts.workflowMode ??
430
+ opts.workflow ??
431
+ opts.approval ??
432
+ opts.approvalState ??
433
+ savedConfig?.workflowMode ??
434
+ savedConfig?.approvalState
435
+ ) === WORKFLOW_MODE_EXECUTE;
436
+ }
437
+
438
+ /**
439
+ * Resolves the final workflow mode by checking opts, then saved config, with fallback.
440
+ */
441
+ export function resolveWorkflowMode(opts: CliOpts = {}, savedConfig = null) {
442
+ return normalizeWorkflowMode(
443
+ opts.workflowMode ??
444
+ opts.workflow ??
445
+ opts.approval ??
446
+ opts.approvalState ??
447
+ savedConfig?.workflowMode ??
448
+ savedConfig?.approvalState
449
+ );
450
+ }
451
+
452
+ /**
453
+ * Strips dynamically appended rule modules from content.
454
+ */
455
+ export function pruneRuleModules(content) {
456
+ const marker = "<!-- RULE MODULE: ";
457
+ const idx = content.indexOf(marker);
458
+ if (idx !== -1) {
459
+ return content.substring(0, idx).trimEnd();
460
+ }
461
+ return content.trimEnd();
462
+ }
463
+
464
+ /**
465
+ * Higher-order function to wrap interactive readline sessions.
466
+ */
467
+ export async function withReadline(callback) {
468
+ if (!process.stdout.isTTY) {
469
+ throw new Error("Interactive terminal required.");
470
+ }
471
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
472
+ try {
473
+ return await callback(rl);
474
+ } finally {
475
+ rl.close();
476
+ }
477
+ }
478
+
479
+ export function toPosixPath(p) {
480
+ return p.replace(/\\/g, "/");
481
+ }
482
+
483
+ export function toRepoRelativePath(cwd, absPath) {
484
+ return toPosixPath(relative(cwd, absPath));
485
+ }
486
+
487
+ /** ?덉??ㅽ듃由?湲곕컲 ?뚰겕?ㅽ럹?댁뒪 猷⑦듃 湲곗? ?곷?寃쎈줈 ??留곹겕/寃쎈줈 異쒕젰???ъ슜 */
488
+ // #746: 티켓은 홈 스토어(~/.deuk/tickets/{uuid}/, 보통 C:)에 살고 워크스페이스 cwd는
489
+ // 다른 드라이브(D:)일 수 있다. 그 경우 relative(wsRoot, abs)는 상대경로를 만들지 못하고
490
+ // "C:/..." 절대경로로 폴백하는데, 그 drive-prefixed 링크는 채팅·webview 어느 쪽에서도
491
+ // 열리지 않는다(클릭 실측). 카드 링크(formatTicketFlowLine)와 동일하게 drive-letter만
492
+ // 떼어낸 슬래시-루트 절대경로("C:/Users/..." → "/Users/...")로 통일하면 양쪽에서 열린다.
493
+ // NOTE(후속): 같은 드라이브일 때 ../상대경로 최적화·linux 동작은 별도 티켓에서 고민.
494
+ export function toWsRelativePath(_cwd, absPath) {
495
+ return toPosixPath(makePath(resolve(absPath))).replace(/^[a-zA-Z]:/, "");
496
+ }
497
+
498
+ // Hangul syllable \u2192 Latin transliteration (Revised Romanization, simplified).
499
+ // Used as a fallback so non-ASCII titles still produce a usable slug.
500
+ const HANGUL_CHO = ["g","kk","n","d","tt","r","m","b","pp","s","ss","","j","jj","ch","k","t","p","h"];
501
+ const HANGUL_JUNG = ["a","ae","ya","yae","eo","e","yeo","ye","o","wa","wae","oe","yo","u","wo","we","wi","yu","eu","ui","i"];
502
+ const HANGUL_JONG = ["","g","kk","gs","n","nj","nh","d","l","lg","lm","lb","ls","lt","lp","lh","m","b","bs","s","ss","ng","j","ch","k","t","p","h"];
503
+
504
+ export function hangulToRoman(input) {
505
+ let out = "";
506
+ for (const ch of String(input || "")) {
507
+ const code = ch.codePointAt(0);
508
+ if (code >= 0xac00 && code <= 0xd7a3) {
509
+ const offset = code - 0xac00;
510
+ const cho = Math.floor(offset / 588);
511
+ const jung = Math.floor((offset % 588) / 28);
512
+ const jong = offset % 28;
513
+ out += HANGUL_CHO[cho] + HANGUL_JUNG[jung] + HANGUL_JONG[jong];
514
+ } else {
515
+ out += ch;
516
+ }
517
+ }
518
+ return out;
519
+ }
520
+
521
+ function asciiSlug(input) {
522
+ return String(input || "")
523
+ .toLowerCase()
524
+ .normalize("NFKD")
525
+ .replace(/[\u0300-\u036f]/g, "")
526
+ .replace(/[^a-z0-9]+/g, "-")
527
+ .replace(/^-+|-+$/g, "");
528
+ }
529
+
530
+ export function toSlug(input) {
531
+ const slug = asciiSlug(input);
532
+ if (slug) return slug;
533
+ // Fallback: transliterate Hangul, then slug again. Keeps Korean titles usable.
534
+ return asciiSlug(hangulToRoman(input));
535
+ }
536
+
537
+ function normalizeWorkspaceLookup(input) {
538
+ return String(input || "")
539
+ .toLowerCase()
540
+ .normalize("NFKC")
541
+ .replace(/[\s._/\-]+/g, "")
542
+ .replace(/[^a-z0-9\u3131-\u318e\uac00-\ud7a3]/g, "");
543
+ }
544
+
545
+ function splitWorkspaceNameParts(input) {
546
+ return String(input || "")
547
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
548
+ .split(/[^A-Za-z0-9]+/)
549
+ .map(part => part.trim())
550
+ .filter(Boolean);
551
+ }
552
+
553
+ export function buildWorkspaceAliases(id, workspacePath, extraAliases = []) {
554
+ const baseName = basename(workspacePath);
555
+ const rawNames = [id, baseName, ...extraAliases].map(value => String(value || "").trim()).filter(Boolean);
556
+ const aliases = new Set(rawNames);
557
+ for (const rawName of rawNames) {
558
+ const slug = toSlug(rawName);
559
+ if (slug) aliases.add(slug);
560
+
561
+ const parts = splitWorkspaceNameParts(rawName);
562
+ if (parts.length > 1) {
563
+ aliases.add(parts.join(" "));
564
+ aliases.add(parts.join("-"));
565
+ }
566
+ if (parts.length > 1 && parts[0].toLowerCase() === "deuk") {
567
+ const withoutDeukParts = parts.slice(1);
568
+ const withoutDeuk = withoutDeukParts.join("-");
569
+ if (withoutDeuk) {
570
+ aliases.add(withoutDeuk);
571
+ aliases.add(withoutDeukParts.join(" "));
572
+ }
573
+ // The bare last part (e.g. "flow", "pack") stays available for explicit
574
+ // `--workspace <name>` queries, which are intentional and exact-matched.
575
+ // It is filtered out of prompt-scan aliases (buildPromptWorkspaceAliases)
576
+ // so it cannot hijack free-form prompt text.
577
+ const lastPart = parts[parts.length - 1];
578
+ if (lastPart) aliases.add(lastPart);
579
+ }
580
+ if (parts.length > 2 && parts[0].toLowerCase() === "deuk" && parts[1].toLowerCase() === "ai") {
581
+ const withoutDeukAiParts = parts.slice(2);
582
+ const withoutDeukAi = withoutDeukAiParts.join("-");
583
+ if (withoutDeukAi) {
584
+ aliases.add(withoutDeukAi);
585
+ aliases.add(withoutDeukAiParts.join(" "));
586
+ }
587
+ }
588
+ }
589
+ return Array.from(aliases);
590
+ }
591
+
592
+ export function buildPromptWorkspaceAliases(workspace) {
593
+ const promptAliases = new Set();
594
+ const rawCanonicalNames = [workspace?.id, basename(workspace?.path || "")]
595
+ .map(value => String(value || "").trim())
596
+ .filter(Boolean);
597
+
598
+ for (const rawName of rawCanonicalNames) {
599
+ promptAliases.add(rawName);
600
+ const slug = toSlug(rawName);
601
+ if (slug) promptAliases.add(slug);
602
+
603
+ const parts = splitWorkspaceNameParts(rawName);
604
+ if (parts.length > 1) {
605
+ promptAliases.add(parts.join(" "));
606
+ promptAliases.add(parts.join("-"));
607
+ }
608
+ }
609
+
610
+ // Existing registry data may contain over-generic single-word aliases
611
+ // (e.g. "Flow", "Pack") that would match unrelated free-form prompt text and
612
+ // redirect to the wrong workspace. Only accept registry aliases specific
613
+ // enough for prompt matching: multi-token (space/hyphen) or sharing the
614
+ // canonical id/basename stem.
615
+ const canonicalSlugs = rawCanonicalNames.map(name => toSlug(name)).filter(Boolean);
616
+ const isSpecificPromptAlias = (alias) => {
617
+ if (/[\s-]/.test(alias)) return true;
618
+ const slug = toSlug(alias);
619
+ if (!slug) return false;
620
+ // Accept only if the alias equals or is more specific than (contains) a
621
+ // canonical slug. Reject bare fragments like "flow" that are merely a
622
+ // substring of the canonical id ("deukagentflow").
623
+ return canonicalSlugs.some(canonical => slug === canonical || slug.includes(canonical));
624
+ };
625
+ for (const registryAlias of workspace?.registryAliases || []) {
626
+ const trimmed = String(registryAlias || "").trim();
627
+ if (trimmed && isSpecificPromptAlias(trimmed)) promptAliases.add(trimmed);
628
+ }
629
+
630
+ return Array.from(promptAliases);
631
+ }
632
+
633
+ export function hasWorkspaceMarker(candidatePath) {
634
+ return existsSync(makePath(candidatePath, WORKSPACE_MARKER_FILE)) ||
635
+ existsSync(makePath(candidatePath, DEUK_ROOT_DIR));
636
+ }
637
+
638
+ function hasLocalProjectBoundary(candidatePath) {
639
+ return existsSync(makePath(candidatePath, ".git")) ||
640
+ existsSync(makePath(candidatePath, "AGENTS.md")) ||
641
+ existsSync(makePath(candidatePath, "PROJECT_RULE.md")) ||
642
+ existsSync(makePath(candidatePath, "package.json"));
643
+ }
644
+
645
+ function directProjectRoot(startDir) {
646
+ const current = resolve(startDir);
647
+ if (hasWorkspaceMarker(current)) return current;
648
+ const parent = dirname(current);
649
+ if (parent !== current && hasWorkspaceMarker(parent)) return parent;
650
+ return null;
651
+ }
652
+
653
+ export function normalizeRegistryPath(value) {
654
+ let p = String(value || "").trim();
655
+ // WSL2: /mnt/d/... ??D:\...
656
+ const wsl2 = p.match(/^\/mnt\/([a-zA-Z])(\/.*)?$/);
657
+ if (wsl2) p = wsl2[1].toUpperCase() + ":" + (wsl2[2] || "\\").replace(/\//g, "\\");
658
+ // WSL1 / Git Bash: /d/... ??D:\...
659
+ const wsl1 = p.match(/^\/([a-zA-Z])(\/.*)?$/);
660
+ if (wsl1) p = wsl1[1].toUpperCase() + ":" + (wsl1[2] || "\\").replace(/\//g, "\\");
661
+ // Windows drive letter: normalize to uppercase
662
+ p = p.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":");
663
+ // #632: a drive-letter path (e.g. D:\workspace\DeukPack) is ABSOLUTE on Windows, but
664
+ // POSIX resolve() — what runs under WSL / Git Bash — does not recognize "X:\" as a root
665
+ // and treats the whole string as relative, prepending process.cwd() and mangling the
666
+ // backslashes into one segment (".../scripts/workspaceDeukPack"). That poisons the
667
+ // ticketKey and makes reconcile fire every command. Resolve drive-letter paths with the
668
+ // win32 resolver so they stay absolute and segmented regardless of the host OS.
669
+ if (/^[A-Za-z]:[\\/]/.test(p)) return win32.resolve(p);
670
+ return resolve(p);
671
+ }
672
+
673
+ function hashWorkspacePath(value) {
674
+ return createHash("sha256").update(normalizeRegistryPath(String(value || "")), "utf8").digest("hex");
675
+ }
676
+
677
+ export function getSiblingWorkspaceRegistryRoot(homeDir = resolveUserHome()) {
678
+ return makePath(homeDir, ".agent-flow", "workspace");
679
+ }
680
+
681
+ export function getSiblingWorkspaceRegistryGroupPath(parentRoot, opts: CliOpts = {}) {
682
+ const homeDir = resolveUserHome(opts);
683
+ return makePath(getSiblingWorkspaceRegistryRoot(homeDir), hashWorkspacePath(parentRoot));
684
+ }
685
+
686
+ export function loadRegistrySiblingWorkspaceCandidates(startDir, opts: CliOpts = {}) {
687
+ const current = resolve(startDir);
688
+ const currentRoot = directProjectRoot(current);
689
+ const parentRoot = currentRoot ? dirname(currentRoot) : dirname(current);
690
+ const registryDir = getSiblingWorkspaceRegistryGroupPath(parentRoot, opts);
691
+ if (!existsSync(registryDir)) return null;
692
+
693
+ const workspaces = [];
694
+ try {
695
+ for (const entry of readdirSync(registryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
696
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
697
+ const registryPath = makePath(registryDir, entry.name);
698
+ let record = null;
699
+ try {
700
+ record = JSON.parse(readFileSync(registryPath, "utf8"));
701
+ } catch {
702
+ continue;
703
+ }
704
+ const workspacePath = normalizeRegistryPath(String(record?.path || ""));
705
+ const id = String(record?.name || basename(workspacePath)).trim();
706
+ if (!id || !workspacePath || !hasWorkspaceMarker(workspacePath)) continue;
707
+ // #080: the user home dir owns ~/.deuk-agent (the STORE) and so passes the marker
708
+ // check above — but home and anything inside the store are never workspaces.
709
+ // Stale "joy"/".deuk-agent" records otherwise hijack workspace dispatch.
710
+ if (isInsideHomeStore(workspacePath)) continue;
711
+ const registryAliases = Array.isArray(record?.aliases)
712
+ ? record.aliases.map(value => String(value || "").trim()).filter(Boolean)
713
+ : [];
714
+ workspaces.push({
715
+ id,
716
+ path: workspacePath,
717
+ aliases: buildWorkspaceAliases(id, workspacePath, registryAliases),
718
+ registryAliases,
719
+ registryPath
720
+ });
721
+ }
722
+ } catch {
723
+ return null;
724
+ }
725
+
726
+ if (workspaces.length === 0) return null;
727
+ return { path: registryDir, root: parentRoot, workspaces, discovered: false, registry: true };
728
+ }
729
+
730
+ // #638: the workspace candidate source is the home ticket store itself —
731
+ // ~/.deuk-agent/tickets/{uuid}/workspace.json. Each folder is self-describing
732
+ // ({uuid,path,name}); the folder's existence IS the registration (single source of
733
+ // truth). No ~/.agent-flow registry, no registry.json fallback — those caches went
734
+ // stale and spawned ambiguous ghosts. A folder without workspace.json simply isn't
735
+ // a candidate yet; it self-heals into one the next time a command runs in it (reconcile
736
+ // writes workspace.json). One readdir + one small JSON per workspace (tens, not
737
+ // thousands) — negligible cost, nothing to drift.
738
+ export function loadAllRegistryWorkspaceCandidates(opts: CliOpts = {}) {
739
+ const homeDir = resolveUserHome(opts);
740
+ const ticketsRoot = makePath([homeDir, DEUK_ROOT_DIR, "tickets"], opts);
741
+ if (!existsSync(ticketsRoot)) return null;
742
+
743
+ const byPath = new Map();
744
+ try {
745
+ for (const ent of readdirSync(ticketsRoot, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
746
+ if (!ent.isDirectory()) continue;
747
+ const dir = makePath(ticketsRoot, ent.name);
748
+
749
+ // Authoritative, self-describing source: workspace.json. No fallback.
750
+ let workspacePath = "", name = "", aliases = [];
751
+ try {
752
+ const meta = JSON.parse(readFileSync(makePath(dir, "workspace.json"), "utf8"));
753
+ workspacePath = normalizeRegistryPath(String(meta?.path || ""));
754
+ name = String(meta?.name || "").trim();
755
+ if (Array.isArray(meta?.aliases)) aliases = meta.aliases.map(a => String(a || "").trim()).filter(Boolean);
756
+ } catch { continue; } // no workspace.json → not a candidate yet
757
+
758
+ // Path must still exist with a workspace marker, and not be the home store.
759
+ if (!workspacePath || !hasWorkspaceMarker(workspacePath)) continue;
760
+ if (isInsideHomeStore(workspacePath)) continue;
761
+ // #638: the user home dir is NOT where workspaces live. Past cwd-fallback bugs
762
+ // minted ghost markers at ~/{Name} (e.g. C:\Users\joy\DeukAgentFlow) that
763
+ // collide by name with the real D:\workspace\{Name}. A workspace directly under
764
+ // the home dir is never a real candidate — exclude it.
765
+ if (isHomeDirectChild(workspacePath, opts)) continue;
766
+ if (!name) name = basename(workspacePath);
767
+
768
+ byPath.set(workspacePath, {
769
+ id: name,
770
+ path: workspacePath,
771
+ aliases: buildWorkspaceAliases(name, workspacePath, aliases),
772
+ registryAliases: aliases,
773
+ });
774
+ }
775
+ } catch {
776
+ return null;
777
+ }
778
+
779
+ // #638: drop nested ghosts — a workspace path that sits INSIDE another candidate's
780
+ // path (e.g. D:\workspace\DeukAgentFlow\DeukAgentFlow under D:\workspace\DeukAgentFlow)
781
+ // is an accidental nested registration, not a real workspace. Keep only top-level paths.
782
+ const all = Array.from(byPath.values());
783
+ const paths = all.map(w => w.path);
784
+ const kept = all.filter(w => !paths.some(p =>
785
+ p !== w.path && (w.path === p + "/" || w.path.startsWith(p + "/") || w.path.startsWith(p + "\\"))
786
+ ));
787
+
788
+ const workspaces = kept.sort((a, b) => a.id.localeCompare(b.id));
789
+ if (workspaces.length === 0) return null;
790
+ return { path: ticketsRoot, root: ticketsRoot, workspaces, discovered: false, registry: true, global: true };
791
+ }
792
+
793
+ export function discoverSiblingWorkspaces(startDir) {
794
+ const current = resolve(startDir);
795
+ const currentRoot = directProjectRoot(current);
796
+ const discoveryRoot = currentRoot ? dirname(currentRoot) : dirname(current);
797
+ const workspaces = [];
798
+
799
+ try {
800
+ for (const item of readdirSync(discoveryRoot, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
801
+ if (!item.isDirectory() || item.name.startsWith(".")) continue;
802
+ const workspacePath = makePath(discoveryRoot, item.name);
803
+ if (!hasWorkspaceMarker(workspacePath)) continue;
804
+ workspaces.push({
805
+ id: item.name,
806
+ path: workspacePath,
807
+ aliases: buildWorkspaceAliases(item.name, workspacePath),
808
+ registryAliases: []
809
+ });
810
+ }
811
+ } catch {
812
+ return null;
813
+ }
814
+
815
+ if (workspaces.length === 0) return null;
816
+ return { path: null, root: discoveryRoot, workspaces, discovered: true };
817
+ }
818
+
819
+ export function loadSiblingWorkspaceCandidates(startDir) {
820
+ return loadRegistrySiblingWorkspaceCandidates(startDir) || discoverSiblingWorkspaces(startDir);
821
+ }
822
+
823
+ export function loadWorkspaceCandidates(startDir, opts: CliOpts = {}) {
824
+ // #645: SSOT-only, NO fallback. The only source of workspace candidates is the
825
+ // home ticket store's workspace.json records (loadAllRegistryWorkspaceCandidates).
826
+ // Every cwd-based fallback (sibling scan, ~/.agent-flow cache) was a ghost
827
+ // factory — a command run in any directory would "discover" and register it.
828
+ // A workspace exists iff it was explicitly registered via `init`. If nothing is
829
+ // registered yet, there are simply no candidates — we never invent them from cwd.
830
+ return loadAllRegistryWorkspaceCandidates(opts);
831
+ }
832
+
833
+ function workspaceQueryMatches(query, workspace) {
834
+ const normalizedQuery = normalizeWorkspaceLookup(query);
835
+ if (!normalizedQuery) return false;
836
+ for (const alias of workspace.aliases || []) {
837
+ const normalizedAlias = normalizeWorkspaceLookup(alias);
838
+ if (!normalizedAlias) continue;
839
+ if (normalizedQuery === normalizedAlias) return true;
840
+ }
841
+ return false;
842
+ }
843
+
844
+ export function findPromptWorkspaceMatches(prompt, candidates) {
845
+ const normalizedPrompt = normalizeWorkspaceLookup(prompt);
846
+ if (!normalizedPrompt || !candidates?.workspaces?.length) return [];
847
+
848
+ const matches = [];
849
+ for (const workspace of candidates.workspaces) {
850
+ let bestAliasLength = 0;
851
+ for (const alias of workspace.aliases || []) {
852
+ const normalizedAlias = normalizeWorkspaceLookup(alias);
853
+ if (normalizedAlias && normalizedPrompt.includes(normalizedAlias)) {
854
+ bestAliasLength = Math.max(bestAliasLength, normalizedAlias.length);
855
+ }
856
+ }
857
+ if (bestAliasLength > 0) matches.push({ workspace, bestAliasLength });
858
+ }
859
+ if (matches.length === 0) return [];
860
+
861
+ const mostSpecificAliasLength = Math.max(...matches.map(match => match.bestAliasLength));
862
+ return matches
863
+ .filter(match => match.bestAliasLength === mostSpecificAliasLength)
864
+ .map(match => match.workspace)
865
+ .sort((a, b) => a.id.localeCompare(b.id));
866
+ }
867
+
868
+
869
+ // Resolves a stable session identifier from agent-injected environment variables.
870
+ // Priority: explicit arg → Claude Code → Codex → Antigravity → VSCODE_PID → "default".
871
+ // Each supported agent injects a per-session UUID so concurrent agents on the same
872
+ // machine get independent flow-cookies and claim files without manual --session-id.
873
+ export function resolveSessionId(explicitId) {
874
+ if (explicitId) return String(explicitId).trim().slice(0, 36);
875
+ const raw =
876
+ process.env.CLAUDE_CODE_SESSION_ID ||
877
+ process.env.CODEX_THREAD_ID ||
878
+ process.env.ANTIGRAVITY_TRAJECTORY_ID ||
879
+ process.env.VSCODE_PID ||
880
+ "";
881
+ return raw.trim().slice(0, 36) || "default";
882
+ }
883
+
884
+ // #675: touch-file cookie — cookie-{workspaceId}-{ticketId}-{sessionId}
885
+ // Lives in ~/.deuk-agent/ (home root). File existence = session owns workspace+ticket.
886
+ // No JSON content; all info is in the filename. TTL via statSync().mtimeMs.
887
+ // Replaces both flow-cookie-*.json AND claim-*.json.
888
+
889
+ const COOKIE_PREFIX = "cookie-";
890
+ const COOKIE_TTL_MS = 30 * 60 * 1000;
891
+
892
+ function sanitizeCookieSegment(s) {
893
+ return String(s || "").toLowerCase().replace(/[^a-z0-9\-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "x";
894
+ }
895
+
896
+ function cookieHomeDir(opts: CliOpts = {}) {
897
+ return makePath([resolveUserHome(opts), DEUK_ROOT_DIR], opts);
898
+ }
899
+
900
+ export function writeCookieFile(workspaceId, ticketId, sessionId, opts: CliOpts = {}) {
901
+ const dir = cookieHomeDir(opts);
902
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
903
+ const wp = sanitizeCookieSegment(workspaceId);
904
+ const tid = sanitizeCookieSegment(ticketId || "none");
905
+ const sid = sanitizeCookieSegment(sessionId);
906
+ const p = makePath([dir, `${COOKIE_PREFIX}${wp}-${tid}-${sid}`], opts);
907
+ writeFileSync(p, "", "utf8");
908
+ }
909
+
910
+ export function readCookieFile(sessionId, opts: CliOpts = {}) {
911
+ const dir = cookieHomeDir(opts);
912
+ if (!existsSync(dir)) return null;
913
+ const sid = sanitizeCookieSegment(sessionId);
914
+ const files = readdirSync(dir).filter(f => f.startsWith(COOKIE_PREFIX) && f.endsWith(`-${sid}`));
915
+ for (const f of files) {
916
+ const parts = f.slice(COOKIE_PREFIX.length).split("-");
917
+ // format: {wp}-{tid}-{sid} where sid can contain dashes — find by suffix match
918
+ const suffix = `-${sid}`;
919
+ const body = f.slice(COOKIE_PREFIX.length, f.length - suffix.length);
920
+ const dashIdx = body.indexOf("-");
921
+ if (dashIdx < 0) continue;
922
+ const wp = body.slice(0, dashIdx);
923
+ const tid = body.slice(dashIdx + 1);
924
+ const p = makePath([dir, f], opts);
925
+ try {
926
+ const mtime = statSync(p).mtimeMs;
927
+ if (Date.now() - mtime > COOKIE_TTL_MS) { try { unlinkSync(p); } catch { } continue; }
928
+ } catch { continue; }
929
+ return { workspaceId: wp, ticketId: tid === "none" ? null : tid, sessionId: sid, path: p };
930
+ }
931
+ return null;
932
+ }
933
+
934
+ export function releaseCookieFile(sessionId, opts: CliOpts = {}) {
935
+ const dir = cookieHomeDir(opts);
936
+ if (!existsSync(dir)) return;
937
+ const sid = sanitizeCookieSegment(sessionId);
938
+ const files = readdirSync(dir).filter(f => f.startsWith(COOKIE_PREFIX) && f.endsWith(`-${sid}`));
939
+ for (const f of files) { try { unlinkSync(makePath([dir, f], opts)); } catch { } }
940
+ }
941
+
942
+ export function listCookiesByTicket(workspaceId, ticketId, opts: CliOpts = {}) {
943
+ const dir = cookieHomeDir(opts);
944
+ if (!existsSync(dir)) return [];
945
+ const wp = sanitizeCookieSegment(workspaceId);
946
+ const tid = sanitizeCookieSegment(ticketId || "none");
947
+ const prefix = `${COOKIE_PREFIX}${wp}-${tid}-`;
948
+ return readdirSync(dir)
949
+ .filter(f => f.startsWith(prefix))
950
+ .map(f => {
951
+ const sid = f.slice(prefix.length);
952
+ const p = makePath([dir, f], opts);
953
+ try { const mtime = statSync(p).mtimeMs; if (Date.now() - mtime > COOKIE_TTL_MS) { try { unlinkSync(p); } catch { } return null; } return { sessionId: sid, path: p, mtime }; } catch { return null; }
954
+ })
955
+ .filter(Boolean);
956
+ }
957
+
958
+ // Legacy compat shims — used by resolveWorkspaceTarget call sites
959
+ export function getWorkspaceCookiePath(_cwd, sessionId, opts: CliOpts = {}) {
960
+ const sid = sanitizeCookieSegment(resolveSessionId(sessionId));
961
+ const dir = cookieHomeDir(opts);
962
+ // Return a glob-friendly sentinel; actual file is found via readCookieFile
963
+ return makePath([dir, `${COOKIE_PREFIX}*-*-${sid}`], opts);
964
+ }
965
+
966
+ export function readWorkspaceCookie(_cwd, sessionId, opts: CliOpts = {}) {
967
+ const sid = resolveSessionId(sessionId);
968
+ const cookie = readCookieFile(sid, opts);
969
+ if (!cookie) return null;
970
+ // Find the registry workspace by id to get the path
971
+ const candidates = opts.candidates || loadAllRegistryWorkspaceCandidates(opts);
972
+ const matched = candidates?.workspaces?.find(w =>
973
+ sanitizeCookieSegment(w.id) === cookie.workspaceId || sanitizeCookieSegment(w.path) === cookie.workspaceId
974
+ );
975
+ return matched ? { workspacePath: matched.path, workspaceId: matched.id } : null;
976
+ }
977
+
978
+ export function writeWorkspaceCookie(_cwd, sessionId, targetPath, opts: CliOpts = {}) {
979
+ const sid = resolveSessionId(sessionId);
980
+ // Derive workspaceId from registry match or basename
981
+ const candidates = opts.candidates || loadAllRegistryWorkspaceCandidates(opts);
982
+ const absPath = normalizeRegistryPath(targetPath);
983
+ const matched = candidates?.workspaces?.find(w => w.path === absPath);
984
+ const workspaceId = matched?.id || basename(absPath);
985
+ // Preserve existing ticketId if cookie already exists for this session
986
+ const existing = readCookieFile(sid, opts);
987
+ releaseCookieFile(sid, opts);
988
+ writeCookieFile(workspaceId, existing?.ticketId || null, sid, opts);
989
+ }
990
+
991
+ export function resolveWorkspaceTarget(startDir, query, opts: CliOpts = {}) {
992
+ // #645: SSOT-only (workspace.json records). No cwd-based sibling fallback.
993
+ // #685: LangGraph 진입 원칙 — workspace는 사람이 --workspace로 명시한다. sessionId
994
+ // 쿠키로 "지난번 워크스페이스"를 추측하는 폴백은 제거됨(env 미전파 환경에서 default로
995
+ // 무너지는 근원). query가 없으면 추측하지 않고 missing-query 노드로 떨어진다.
996
+ const candidates = opts.candidates || loadAllRegistryWorkspaceCandidates(opts);
997
+
998
+ if (!candidates) return null;
999
+ const rawQuery = String(query || "").trim();
1000
+ if (!rawQuery) {
1001
+ // workspace is resolved ONLY from an explicit query. No cookie/cwd inference.
1002
+ return { candidates, target: null, reason: "missing-query" };
1003
+ }
1004
+
1005
+ const matches = candidates.workspaces
1006
+ .filter(workspace => workspaceQueryMatches(rawQuery, workspace))
1007
+ .sort((a, b) => a.id.localeCompare(b.id));
1008
+
1009
+ if (matches.length === 0) return { candidates, target: null, reason: "no-match" };
1010
+ if (matches.length > 1) {
1011
+ // Normalized lookup collapses separators, so distinct names like
1012
+ // ".deuk-agent" (home store) and "DeukAgent" (workspace) both reduce to
1013
+ // "deukagent" and tie. Break the tie with a case-insensitive exact match
1014
+ // on an original (non-normalized) alias before declaring it ambiguous.
1015
+ const exact = matches.filter(workspace =>
1016
+ (workspace.aliases || []).some(
1017
+ alias => String(alias).toLowerCase() === rawQuery.toLowerCase()
1018
+ )
1019
+ );
1020
+ if (exact.length === 1) {
1021
+ return { candidates, target: exact[0], reason: "matched", score: 100 };
1022
+ }
1023
+ return { candidates, target: null, reason: "ambiguous", matches };
1024
+ }
1025
+
1026
+ return { candidates, target: matches[0], reason: "matched", score: 100 };
1027
+ }
1028
+
1029
+ export function buildWorkspaceGuide(startDir, opts: CliOpts = {}) {
1030
+ const hostContext = resolveWorkspaceContext(startDir);
1031
+ // #645: SSOT-only (workspace.json records). No cwd-based sibling fallback.
1032
+ const candidates = loadAllRegistryWorkspaceCandidates(opts);
1033
+ const explicitQuery = opts.workspace || "";
1034
+ const prompt = opts.prompt || "";
1035
+ let resolution = explicitQuery ? resolveWorkspaceTarget(startDir, explicitQuery, { candidates }) : null;
1036
+ let promptMatches = [];
1037
+ let recommendedTarget = resolution?.target || null;
1038
+ let reason = "";
1039
+
1040
+ if (recommendedTarget) {
1041
+ reason = `explicit workspace matched: ${explicitQuery}`;
1042
+ } else if (candidates?.workspaces?.length && prompt) {
1043
+ promptMatches = findPromptWorkspaceMatches(prompt, candidates);
1044
+ if (promptMatches.length === 1) {
1045
+ recommendedTarget = promptMatches[0];
1046
+ reason = `prompt matched workspace alias: ${recommendedTarget.id}`;
1047
+ } else if (promptMatches.length > 1) {
1048
+ reason = `prompt matched multiple workspaces: ${promptMatches.map(workspace => workspace.id).join(", ")}`;
1049
+ }
1050
+ }
1051
+
1052
+ if (!reason) {
1053
+ if (!candidates?.workspaces?.length) reason = "no sibling workspace candidates found";
1054
+ else reason = "no explicit workspace target detected";
1055
+ }
1056
+
1057
+ return {
1058
+ hostWorkspace: {
1059
+ label: hostContext.breadcrumb,
1060
+ path: hostContext.root
1061
+ },
1062
+ candidates: candidates?.workspaces || [],
1063
+ recommendedTarget,
1064
+ reason,
1065
+ requiresConfirmation: !recommendedTarget && Boolean(candidates?.workspaces?.length > 1),
1066
+ promptMatches
1067
+ };
1068
+ }
1069
+
1070
+ export function requireNonEmptySlug(input, fieldName = "value") {
1071
+ const slug = toSlug(input);
1072
+ if (slug) return slug;
1073
+
1074
+ const received = String(input || "").trim() || "(empty)";
1075
+ throw new Error(
1076
+ `[VALIDATION FAILED] ${fieldName} must produce a non-empty ASCII slug. ` +
1077
+ `Received: ${JSON.stringify(received)}. ` +
1078
+ `Use an ASCII title such as "basic-protocol-pack-unpack-test-redefinition".`
1079
+ );
1080
+ }
1081
+
1082
+ export function toSnakeCaseKey(input) {
1083
+ return toSlug(input).replace(/-/g, "_");
1084
+ }
1085
+
1086
+ export function formatTimestampForFile(d = new Date()) {
1087
+ const y = d.getFullYear();
1088
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1089
+ const day = String(d.getDate()).padStart(2, "0");
1090
+ const hh = String(d.getHours()).padStart(2, "0");
1091
+ const mm = String(d.getMinutes()).padStart(2, "0");
1092
+ const ss = String(d.getSeconds()).padStart(2, "0");
1093
+ return `${y}${m}${day}-${hh}${mm}${ss}`;
1094
+ }
1095
+
1096
+ export function makeEntryId() {
1097
+ return `000-generated-${Date.now().toString(36)}`;
1098
+ }
1099
+
1100
+ export function findFileRecursively(dir, fileName) {
1101
+ if (!existsSync(dir)) return null;
1102
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1103
+ for (const entry of entries) {
1104
+ const res = makePath(dir, entry.name);
1105
+ if (entry.isDirectory()) {
1106
+ const found = findFileRecursively(res, fileName);
1107
+ if (found) return found;
1108
+ } else if (entry.name === fileName) {
1109
+ return res;
1110
+ }
1111
+ }
1112
+ return null;
1113
+ }
1114
+
1115
+ export function detectProjectFromBody(body) {
1116
+ const content = String(body || "");
1117
+ const lines = content.split("\n");
1118
+ for (const line of lines) {
1119
+ const l = line.trim();
1120
+ if (l.toLowerCase().startsWith("project:")) {
1121
+ return l.split(":")[1].trim();
1122
+ }
1123
+ if (l.startsWith("# Project:") || l.startsWith("## Project:")) {
1124
+ return l.split(":")[1].trim();
1125
+ }
1126
+ }
1127
+ return "global";
1128
+ }
1129
+
1130
+ export function deriveTopicFromBaseName(baseName) {
1131
+ const raw = String(baseName || "").split(".")[0];
1132
+ // Remove trailing timestamp if present (e.g. title-20260426-071208)
1133
+ const parts = raw.split("-");
1134
+ if (parts.length >= 3) {
1135
+ const last = parts[parts.length - 1];
1136
+ const prev = parts[parts.length - 2];
1137
+ if (last.length === 6 && prev.length === 8) {
1138
+ return parts.slice(0, -2).join("-");
1139
+ }
1140
+ }
1141
+ return toSlug(raw) || raw.toLowerCase();
1142
+ }
1143
+
1144
+ export function resolveReferencedTicketPath(opts) {
1145
+ if (!opts.ref) return null;
1146
+ const refAbs = makePath(opts.cwd, opts.ref);
1147
+ if (!existsSync(refAbs)) {
1148
+ throw new Error("--ref file not found: " + opts.ref);
1149
+ }
1150
+ return toRepoRelativePath(opts.cwd, refAbs);
1151
+ }
1152
+
1153
+ export function inferRefTitleAndTopic(opts) {
1154
+ if (!opts.ref) return null;
1155
+ const refAbs = makePath(opts.cwd, opts.ref);
1156
+
1157
+ let body = "";
1158
+ try {
1159
+ body = readFileSync(refAbs, "utf8");
1160
+ } catch (err) {
1161
+ if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read ref ${refAbs}:`, err);
1162
+ return null;
1163
+ }
1164
+
1165
+ const lines = body.split("\n");
1166
+ let title = "";
1167
+ for (const line of lines) {
1168
+ const l = line.trim();
1169
+ if (l.startsWith("## Task:")) {
1170
+ title = l.replace("## Task:", "").trim();
1171
+ break;
1172
+ }
1173
+ if (l.startsWith("# ")) {
1174
+ title = l.replace("# ", "").trim();
1175
+ break;
1176
+ }
1177
+ }
1178
+
1179
+ const base = basename(refAbs).split(".")[0];
1180
+ const finalTitle = title || base;
1181
+ return {
1182
+ title: String(finalTitle).trim(),
1183
+ slug: toSlug(finalTitle),
1184
+ };
1185
+ }
1186
+
1187
+ export function parseFrontMatter(content) {
1188
+ const lines = content.split(/\r?\n/);
1189
+ if (lines.length < 3 || lines[0].trim() !== "---") {
1190
+ return { meta: {}, content, parseError: null };
1191
+ }
1192
+
1193
+ let endIdx = -1;
1194
+ for (let i = 1; i < lines.length; i++) {
1195
+ if (lines[i].trim() === "---") {
1196
+ endIdx = i;
1197
+ break;
1198
+ }
1199
+ }
1200
+
1201
+ if (endIdx === -1) return { meta: {}, content, parseError: null };
1202
+
1203
+ const metaStr = lines.slice(1, endIdx).join("\n");
1204
+ const bodyStr = lines.slice(endIdx + 1).join("\n");
1205
+ try {
1206
+ const meta = YAML.parse(metaStr);
1207
+ return { meta: meta || {}, content: bodyStr, parseError: null };
1208
+ } catch (err) {
1209
+ return { meta: {}, content: bodyStr, parseError: err.message || "invalid frontmatter" };
1210
+ }
1211
+ }
1212
+
1213
+ // Strip a leading frontmatter block from content so that round-tripping through
1214
+ // parseFrontMatter ??stringifyFrontMatter never produces a double-frontmatter file.
1215
+ export function stripLeadingFrontMatter(content) {
1216
+ const original = String(content || "");
1217
+ // Skip leading whitespace/newlines: parseFrontMatter leaves a blank line in
1218
+ // front of any second frontmatter block (e.g. a duplicated header from a broken
1219
+ // migration), so the "---" we want to strip is not necessarily at index 0.
1220
+ const s = original.replace(/^\s+/, "");
1221
+ if (!s.startsWith("---")) return original;
1222
+ const rest = s.slice(3);
1223
+ const end = rest.search(/\n---(\s*\n|$)/);
1224
+ if (end === -1) return original;
1225
+ return rest.slice(end + 4).replace(/^\n+/, "");
1226
+ }
1227
+
1228
+ export function stringifyFrontMatter(meta, content) {
1229
+ const cleanMeta = sanitizeFrontMatterMeta(meta);
1230
+ const yamlStr = YAML.stringify(cleanMeta).trim();
1231
+ const cleanContent = stripLeadingFrontMatter(content);
1232
+ // Double newline after frontmatter to ensure markdown rendering integrity
1233
+ return `---\n${yamlStr}\n---\n\n\n${cleanContent.trim()}\n`;
1234
+ }
1235
+
1236
+ export function sanitizeFrontMatterMeta(meta: CliOpts = {}) {
1237
+ const cleanMeta = { ...meta };
1238
+ for (const key of [
1239
+ "path",
1240
+ "absPath",
1241
+ "absolutePath",
1242
+ "relativePath",
1243
+ "sourcePath",
1244
+ "targetPath",
1245
+ "workspacePath",
1246
+ "rulesPath",
1247
+ "ticketPath",
1248
+ "docmeta"
1249
+ ]) {
1250
+ delete cleanMeta[key];
1251
+ }
1252
+ // Remove redundant or default fields to keep frontmatter slim
1253
+ if (cleanMeta.project === 'global') delete cleanMeta.project;
1254
+ if (!cleanMeta.submodule) delete cleanMeta.submodule;
1255
+
1256
+ // Normalize date format if it looks like an ISO string
1257
+ if (typeof cleanMeta.createdAt === 'string' && cleanMeta.createdAt.includes('T')) {
1258
+ cleanMeta.createdAt = cleanMeta.createdAt.replace('T', ' ').split('.')[0];
1259
+ }
1260
+ return cleanMeta;
1261
+ }
1262
+
1263
+ /**
1264
+ * Returns true if semver a is less than b
1265
+ */
1266
+ export function semverLt(a, b) {
1267
+ const pa = String(a || "0").replace(/[^0-9.]/g, "").split(".").map(Number);
1268
+ const pb = String(b || "0").replace(/[^0-9.]/g, "").split(".").map(Number);
1269
+ for (let i = 0; i < 3; i++) {
1270
+ const na = pa[i] ?? 0, nb = pb[i] ?? 0;
1271
+ if (na !== nb) return na < nb;
1272
+ }
1273
+ return false;
1274
+ }
1275
+
1276
+ export async function checkUpdateNotifier() {
1277
+ try {
1278
+ const { fileURLToPath } = await import("url");
1279
+ const pkgPath = makePath(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1280
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1281
+ const currentVersion = pkg.version;
1282
+ const controller = new AbortController();
1283
+ const timeoutId = setTimeout(() => controller.abort(), 800);
1284
+ if (typeof timeoutId.unref === "function") timeoutId.unref();
1285
+ const res = await fetch("https://registry.npmjs.org/deuk-agent-flow/latest", {
1286
+ signal: controller.signal
1287
+ }).finally(() => {
1288
+ clearTimeout(timeoutId);
1289
+ });
1290
+ if (res.ok) {
1291
+ const data = await res.json() as CliOpts;
1292
+ // Only notify when registry version is strictly newer than local (handles local dev symlink case)
1293
+ if (data.version && semverLt(currentVersion, data.version)) {
1294
+ console.warn(`\n\x1b[33m?뮕 Update available! ${currentVersion} ??${data.version}\x1b[0m`);
1295
+ console.warn(`\x1b[36mRun 'npm install -g deuk-agent-flow', then 'deuk-agent-flow init' from the repo or workspace root.\x1b[0m\n`);
1296
+ }
1297
+ }
1298
+ } catch(e) {
1299
+ // Ignore timeout or network errors silently
1300
+ }
1301
+ }
1302
+
1303
+ export const DEFAULT_IGNORE_DIRS = ["node_modules", ".git", DEUK_ROOT_DIR, "tmp", "temp", ".tmp", ".cache"];
1304
+
1305
+ /**
1306
+ * Resolves all potential ticket directories for a given path.
1307
+ * Returns { primary, legacy: [] }
1308
+ */
1309
+ export function resolveTicketSystemPaths(cwd) {
1310
+ return {
1311
+ primary: makePath(cwd, DEUK_ROOT_DIR, TICKET_SUBDIR),
1312
+ legacy: []
1313
+ };
1314
+ }
1315
+
1316
+ // Finds the nearest ancestor that owns a .deuk-agent dir (the workspace root).
1317
+ // Exported so commands can locate the workspace WITHOUT going through
1318
+ // detectConsumerTicketDir (which now returns the home ticket dir, #622).
1319
+ export function findWorkspaceRoot(startDir) {
1320
+ // #080: the user home dir owns ~/.deuk-agent (the ticket STORE, not a workspace
1321
+ // marker). Climbing past a non-workspace cwd must never land on home — that minted
1322
+ // a "joy"-style ghost workspace and nested ticket dirs inside the store.
1323
+ const home = resolve(resolveUserHome());
1324
+ let curr = resolve(startDir);
1325
+ while (curr && curr !== dirname(curr)) {
1326
+ if (curr === home) return null;
1327
+ if (hasWorkspaceMarker(curr)) return curr;
1328
+ if (hasLocalProjectBoundary(curr) && curr !== resolve(startDir)) return null;
1329
+ curr = dirname(curr);
1330
+ }
1331
+ if (curr !== home && hasWorkspaceMarker(curr)) return curr;
1332
+ return null;
1333
+ }
1334
+
1335
+ // #622/#623: ticket data lives in ~/.deuk-agent/tickets/{ticketKey}/, NOT in the
1336
+ // workspace git tree. detectConsumerTicketDir is a PURE READ — it resolves the home
1337
+ // ticket dir for the workspace WITHOUT side effects (no marker creation, no file
1338
+ // moves, no registry writes). The actual migration (reconcile) is triggered once on
1339
+ // ticket-command entry and on init via ensureWorkspaceMigrated() (#623), so the many
1340
+ // detect callers (index/scan/parser/document reads) never mutate state.
1341
+ export function detectConsumerTicketDir(startDir, opts: CliOpts = {}) {
1342
+ const root = findWorkspaceRoot(startDir);
1343
+ if (!root) return null;
1344
+ // #626: findWorkspaceRoot walks ANCESTORS for a .deuk-agent dir. From a non-workspace
1345
+ // cwd (e.g. %TEMP%\foo) it climbs all the way to ~/.deuk-agent and returns the HOME
1346
+ // dir itself — which is the ticket STORE, not a workspace. Treating it as one yields a
1347
+ // doubled `tickets/.deuk-agent/tickets/main` path and scatters reads. A non-workspace
1348
+ // cwd has no ticket dir to read: return null rather than mis-resolving onto the store.
1349
+ try {
1350
+ const home = ticketHomeModule();
1351
+ if (home.isHomeStoreRoot(root, opts)) return null;
1352
+ } catch { /* fall through to legacy resolution below */ }
1353
+ try {
1354
+ const home = ticketHomeModule();
1355
+ // Resolve the home ticket dir from already-known state only. Prefer the registry
1356
+ // entry (keyed by the existing marker id); fall back to basename when not yet
1357
+ // migrated. Crucially, this reads — it does not create a marker or move files.
1358
+ return home.resolveHomeTicketDirForWorkspace(root, opts);
1359
+ } catch {
1360
+ // If home resolution fails for any reason, fall back to the in-workspace path
1361
+ // so the tool keeps working rather than losing the ticket system entirely.
1362
+ return resolveTicketSystemPaths(root).primary;
1363
+ }
1364
+ }
1365
+
1366
+ // The WORKSPACE root (where .deuk-agent lives) — distinct from where tickets are
1367
+ // stored. Since #622 moved ticket storage to home, this must use findWorkspaceRoot
1368
+ // directly; deriving it from detectConsumerTicketDir would now yield the home dir
1369
+ // and corrupt every workspace breadcrumb/identity that depends on it.
1370
+ export function resolveConsumerTicketRoot(startDir, opts: CliOpts = {}) {
1371
+ return findWorkspaceRoot(startDir);
1372
+ }
1373
+
1374
+ // #080: true when cwd is the user's ~/.deuk-agent store or anywhere inside it. The
1375
+ // store holds ticket data ({key}/...), NOT workspaces; registering any path under it
1376
+ // nests tickets/{key}/.deuk-agent/tickets and mints ghost keys. Mirrors the home guard
1377
+ // in cli-ticket-home.isHomeStoreRoot but lives here to avoid a circular import.
1378
+ function isInsideHomeStore(cwd, opts: CliOpts = {}) {
1379
+ const here = normalizeRegistryPath(resolve(String(cwd || "")));
1380
+ const home = normalizeRegistryPath(resolveUserHome(opts));
1381
+ if (here === home) return true;
1382
+ const store = normalizeRegistryPath(makePath([resolveUserHome(opts), DEUK_ROOT_DIR], opts));
1383
+ return here === store || here.startsWith(store + "/");
1384
+ }
1385
+
1386
+ // #638: true when path is a DIRECT child of the user home dir (e.g. ~/DeukAgentFlow).
1387
+ // Workspaces live in project dirs (D:\workspace\...), never directly under home;
1388
+ // such paths are ghost markers minted by past cwd-fallback bugs.
1389
+ export function isHomeDirectChild(p, opts: CliOpts = {}) {
1390
+ // normalizeRegistryPath already yields the canonical (Windows-style) path; do NOT
1391
+ // run it through path.resolve under POSIX node — that mangles "C:\..\.." (#632).
1392
+ const here = normalizeRegistryPath(String(p || ""));
1393
+ const home = normalizeRegistryPath(resolveUserHome(opts));
1394
+ for (const sep of ["/", "\\"]) {
1395
+ if (here.startsWith(home + sep)) {
1396
+ const rest = here.slice(home.length + 1);
1397
+ return rest.length > 0 && !rest.includes("/") && !rest.includes("\\");
1398
+ }
1399
+ }
1400
+ return false;
1401
+ }
1402
+
1403
+ // #626: the authoritative answer to "is this cwd a real workspace I may register?".
1404
+ // findWorkspaceRoot only finds an ancestor that ALREADY owns a .deuk-agent marker, so
1405
+ // callers used `findWorkspaceRoot(cwd) || cwd` to also cover first-time registration —
1406
+ // but that `|| cwd` fallback turns ANY cwd (a %TEMP% dir, an arbitrary subfolder) into
1407
+ // a "workspace", spawning ghost ticketKeys and scattering tickets. Replace that pattern
1408
+ // with this: return the marker-owning root if one exists; otherwise accept cwd ONLY when
1409
+ // it is itself a project boundary (.git / package.json / AGENTS.md / PROJECT_RULE.md) —
1410
+ // i.e. a place worth migrating on first run. Return null for everything else so reconcile
1411
+ // is skipped rather than minting a ghost. An explicit --workspace always wins.
1412
+ export function resolveRealWorkspaceRoot(cwd, opts: CliOpts = {}) {
1413
+ if (opts && opts.workspace) return normalizeRegistryPath(opts.workspace);
1414
+ // #080: NEVER register anything inside the home ticket store (~/.deuk-agent).
1415
+ // Each {key}/ folder there carries its own .deuk-agent subtree, so findWorkspaceRoot
1416
+ // would treat it as a workspace and recurse — nesting tickets/{key}/.deuk-agent/tickets
1417
+ // and spawning ghost keys. Guard the whole store subtree up front.
1418
+ if (isInsideHomeStore(cwd, opts)) return null;
1419
+ // #645: a workspace is ONLY a directory that already owns a .deuk-agent marker
1420
+ // (findWorkspaceRoot walks up to find one). We do NOT promote a bare cwd to a new
1421
+ // workspace just because it looks like a project boundary — that cwd-promotion was
1422
+ // a ghost factory. New workspaces are created exclusively by `deuk-agent-flow init`,
1423
+ // which writes the marker; thereafter findWorkspaceRoot finds it. No marker → null.
1424
+ return findWorkspaceRoot(cwd) || null;
1425
+ }
1426
+
1427
+ export function resolveWorkspaceContext(startDir = "") {
1428
+ // #633: NO cwd fallback. If startDir has no real .deuk-agent workspace marker,
1429
+ // root is "" — we must NOT treat the cwd (e.g. the home dir C:\Users\joy) as a
1430
+ // workspace, which would surface a bogus "joy" breadcrumb and drive maintenance
1431
+ // into null-path crashes. Callers that genuinely need a path fall back themselves.
1432
+ const root = resolveConsumerTicketRoot(startDir) || "";
1433
+ return {
1434
+ root,
1435
+ breadcrumb: root ? basename(root) : ""
1436
+ };
1437
+ }
1438
+
1439
+ export function isInsideGitWorkTree(cwd) {
1440
+ let curr = resolve(cwd);
1441
+ while (curr && curr !== dirname(curr)) {
1442
+ if (isGitWorkTreeMarker(makePath(curr, ".git"))) return true;
1443
+ curr = dirname(curr);
1444
+ }
1445
+ return isGitWorkTreeMarker(makePath(curr, ".git"));
1446
+ }
1447
+
1448
+ function resolveGitWorkTreeRoot(startDir) {
1449
+ let curr = resolve(startDir);
1450
+ while (curr && curr !== dirname(curr)) {
1451
+ if (isGitWorkTreeMarker(makePath(curr, ".git"))) return curr;
1452
+ curr = dirname(curr);
1453
+ }
1454
+ if (isGitWorkTreeMarker(makePath(curr, ".git"))) return curr;
1455
+ return null;
1456
+ }
1457
+
1458
+ function isGitWorkTreeMarker(gitMarkerPath) {
1459
+ if (!existsSync(gitMarkerPath)) return false;
1460
+ const markerStat = statSync(gitMarkerPath);
1461
+ if (!markerStat.isDirectory()) return markerStat.isFile();
1462
+ return existsSync(makePath(gitMarkerPath, "HEAD")) || existsSync(makePath(gitMarkerPath, "commondir"));
1463
+ }
1464
+
1465
+ /**
1466
+ * Unified workspace/submodule discovery.
1467
+ */
1468
+ export function discoverAllWorkspaces(baseCwd, ignoreDirs = DEFAULT_IGNORE_DIRS, out: Set<string> = new Set<string>()) {
1469
+ if (!existsSync(baseCwd)) return Array.from(out);
1470
+
1471
+ const paths = resolveTicketSystemPaths(baseCwd);
1472
+ const isWorkspaceRoot = existsSync(paths.primary) || paths.legacy.length > 0;
1473
+ if (isWorkspaceRoot) {
1474
+ out.add(baseCwd);
1475
+ // Stop descending once a workspace root is found.
1476
+ // This prevents nested child workspaces from being treated as independent roots.
1477
+ return Array.from(out);
1478
+ }
1479
+
1480
+ try {
1481
+ const entries = readdirSync(baseCwd, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1482
+ for (const ent of entries) {
1483
+ if (!ent.isDirectory()) continue;
1484
+ if (ignoreDirs.includes(ent.name) || ent.name.startsWith(".deuk-agent")) continue;
1485
+ discoverAllWorkspaces(makePath(baseCwd, ent.name), ignoreDirs, out);
1486
+ }
1487
+ } catch (err) {
1488
+ if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read directory ${baseCwd}:`, err);
1489
+ }
1490
+ return Array.from(out);
1491
+ }
1492
+
1493
+ async function probeMcpUrl(url) {
1494
+ const methods = ["HEAD", "GET"];
1495
+
1496
+ for (const method of methods) {
1497
+ const controller = new AbortController();
1498
+ const timeoutId = setTimeout(() => controller.abort(), 1000);
1499
+ try {
1500
+ const res = await fetch(url, { method, signal: controller.signal });
1501
+ if (res.body?.cancel) await res.body.cancel().catch(() => {});
1502
+ if (res.ok || res.status === 405) return true;
1503
+ } catch (err) {
1504
+ if (process.env.DEBUG) console.warn(`[DEBUG] SSE ${method} ping failed for ${url}:`, err);
1505
+ } finally {
1506
+ clearTimeout(timeoutId);
1507
+ }
1508
+ }
1509
+
1510
+ return false;
1511
+ }
1512
+
1513
+ /**
1514
+ * Checks if the deuk-agent-context MCP server is active for the given workspace.
1515
+ * Detects .mcp.json, .cursor/mcp.json, or .vscode/mcp.json and pings SSE servers if applicable.
1516
+ */
1517
+ export function collectMcpConfigStatuses(cwd) {
1518
+ const mcpPaths = [
1519
+ makePath(cwd, ".mcp.json"),
1520
+ makePath(cwd, ".cursor", "mcp.json"),
1521
+ makePath(cwd, ".vscode", "mcp.json")
1522
+ ];
1523
+ const statuses = [];
1524
+ for (const p of mcpPaths) {
1525
+ if (existsSync(p)) {
1526
+ try {
1527
+ const config = JSON.parse(readFileSync(p, "utf8"));
1528
+ const servers = config.mcpServers || config.servers || {};
1529
+ const deuk = servers["deuk-agent-context"] || servers["deuk_agent_context"];
1530
+ if (deuk) {
1531
+ statuses.push({
1532
+ path: p,
1533
+ transport: deuk.command ? "stdio" : (deuk.url ? "sse" : "unknown"),
1534
+ command: deuk.command || "",
1535
+ url: deuk.url || ""
1536
+ });
1537
+ }
1538
+ } catch (err) {
1539
+ if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse MCP config ${p}: ${err.message}`);
1540
+ continue;
1541
+ }
1542
+ }
1543
+ }
1544
+ return statuses;
1545
+ }
1546
+
1547
+ /**
1548
+ * Checks if the deuk-agent-context MCP server is active for the given workspace.
1549
+ * Detects .mcp.json, .cursor/mcp.json, or .vscode/mcp.json and pings SSE servers if applicable.
1550
+ */
1551
+ export async function isMcpActive(cwd) {
1552
+ const statuses = collectMcpConfigStatuses(cwd);
1553
+ for (const status of statuses) {
1554
+ if (status.command) return true; // Stdio is managed by IDE
1555
+ if (status.url && await probeMcpUrl(status.url)) return true;
1556
+ }
1557
+ return false;
1558
+ }
1559
+
1560
+