deuk-agent-flow 4.0.37 → 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.
- package/CHANGELOG.ko.md +282 -0
- package/CHANGELOG.md +788 -120
- package/LICENSE +0 -0
- package/README.ko.md +97 -17
- package/README.md +108 -25
- package/bin/deuk-agent-flow.js +11 -13
- package/bin/deuk-agent-rule.js +1 -1
- package/bundled/README.md +3 -0
- package/bundled/deuk-agent-flow.vsix +0 -0
- package/core-rules/AGENTS.md +30 -118
- package/docs/architecture.ko.md +155 -2
- package/docs/architecture.md +155 -2
- package/docs/assets/agentflow-panel-skills.png +0 -0
- package/docs/assets/agentflow-panel.png +0 -0
- package/docs/how-it-works.ko.md +109 -52
- package/docs/how-it-works.md +128 -71
- package/docs/principles.ko.md +68 -68
- package/docs/principles.md +68 -68
- package/docs/usage-guide.ko.md +251 -212
- package/package.json +41 -34
- package/scripts/bundle-vscode-vsix.ts +67 -0
- package/scripts/{cli-args.mjs → cli-args.ts} +49 -12
- package/scripts/cli-init-commands.ts +99 -0
- package/scripts/cli-init-logic.ts +46 -0
- package/scripts/{cli-prompts.mjs → cli-prompts.ts} +19 -34
- package/scripts/{cli-rule-compiler.mjs → cli-rule-compiler.ts} +128 -112
- package/scripts/cli-skill-commands.ts +707 -0
- package/scripts/{cli-telemetry-commands.mjs → cli-telemetry-commands.ts} +25 -22
- package/scripts/cli-ticket-command-shared.ts +4 -0
- package/scripts/cli-ticket-commands.ts +3723 -0
- package/scripts/cli-ticket-index.ts +283 -0
- package/scripts/{cli-ticket-migration.mjs → cli-ticket-migration.ts} +44 -68
- package/scripts/cli-ticket-parser.ts +100 -0
- package/scripts/{cli-usage-commands.mjs → cli-usage-commands.ts} +325 -326
- package/scripts/cli-utils.ts +1560 -0
- package/scripts/cli.ts +695 -0
- package/scripts/{lint-md.mjs → lint-md.ts} +32 -42
- package/scripts/lint-rules.ts +203 -0
- package/scripts/{merge-logic.mjs → merge-logic.ts} +44 -44
- package/scripts/{plan-parser.mjs → plan-parser.ts} +53 -53
- package/templates/MODULE_RULE_TEMPLATE.md +11 -11
- package/templates/PROJECT_RULE.md +46 -47
- package/templates/TICKET_TEMPLATE.ko.md +48 -44
- package/templates/TICKET_TEMPLATE.md +48 -44
- package/templates/project-memory.md +19 -0
- package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +25 -23
- package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -19
- package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -26
- package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -30
- package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -26
- package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -15
- package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -34
- package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -32
- package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -33
- package/templates/rules.d/deukcontext-mcp.md +31 -31
- package/templates/rules.d/platform-coexistence.md +29 -29
- package/templates/skills/context-recall/SKILL.md +3 -1
- package/templates/skills/doc-sync/SKILL.md +111 -0
- package/templates/skills/generated-file-guard/SKILL.md +3 -1
- package/templates/skills/persona-maid/SKILL.md +65 -0
- package/templates/skills/project-pilot/SKILL.md +13 -52
- package/templates/skills/safe-refactor/SKILL.md +3 -1
- package/templates/skills/ticket-status-surface/SKILL.md +17 -0
- package/core-rules/GEMINI.md +0 -7
- package/docs/badges/npm-downloads.json +0 -8
- package/scripts/cli-init-commands.mjs +0 -1750
- package/scripts/cli-init-logic.mjs +0 -64
- package/scripts/cli-skill-commands.mjs +0 -201
- package/scripts/cli-ticket-commands.mjs +0 -2427
- package/scripts/cli-ticket-index.mjs +0 -298
- package/scripts/cli-ticket-parser.mjs +0 -209
- package/scripts/cli-utils.mjs +0 -602
- package/scripts/cli.mjs +0 -256
- package/scripts/lint-rules.mjs +0 -196
- package/scripts/publish-dual-npm.mjs +0 -141
- package/scripts/smoke-npm-docker.mjs +0 -102
- package/scripts/smoke-npm-local.mjs +0 -109
- 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
|
+
|