deuk-agent-flow 4.0.19
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 +223 -0
- package/CHANGELOG.md +227 -0
- package/LICENSE +184 -0
- package/README.ko.md +282 -0
- package/README.md +270 -0
- package/bin/deuk-agent-flow.js +50 -0
- package/bin/deuk-agent-rule.js +2 -0
- package/core-rules/AGENTS.md +153 -0
- package/core-rules/GEMINI.md +7 -0
- package/docs/architecture.ko.md +34 -0
- package/docs/architecture.md +33 -0
- package/docs/assets/architecture-v3.png +0 -0
- package/docs/how-it-works.ko.md +52 -0
- package/docs/how-it-works.md +71 -0
- package/docs/principles.ko.md +68 -0
- package/docs/principles.md +68 -0
- package/docs/usage-guide.ko.md +212 -0
- package/package.json +96 -0
- package/scripts/cli-args.mjs +200 -0
- package/scripts/cli-init-commands.mjs +1799 -0
- package/scripts/cli-init-logic.mjs +64 -0
- package/scripts/cli-prompts.mjs +104 -0
- package/scripts/cli-rule-compiler.mjs +112 -0
- package/scripts/cli-skill-commands.mjs +201 -0
- package/scripts/cli-telemetry-commands.mjs +599 -0
- package/scripts/cli-ticket-commands.mjs +2393 -0
- package/scripts/cli-ticket-index.mjs +298 -0
- package/scripts/cli-ticket-migration.mjs +320 -0
- package/scripts/cli-ticket-parser.mjs +209 -0
- package/scripts/cli-usage-commands.mjs +326 -0
- package/scripts/cli-utils.mjs +587 -0
- package/scripts/cli.mjs +246 -0
- package/scripts/lint-md.mjs +267 -0
- package/scripts/lint-rules.mjs +186 -0
- package/scripts/merge-logic.mjs +44 -0
- package/scripts/plan-parser.mjs +53 -0
- package/scripts/publish-dual-npm.mjs +141 -0
- package/scripts/smoke-npm-docker.mjs +102 -0
- package/scripts/smoke-npm-local.mjs +109 -0
- package/scripts/update-download-badge.mjs +107 -0
- package/templates/MODULE_RULE_TEMPLATE.md +11 -0
- package/templates/PROJECT_RULE.md +47 -0
- package/templates/TICKET_TEMPLATE.ko.md +44 -0
- package/templates/TICKET_TEMPLATE.md +44 -0
- package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +23 -0
- package/templates/project-pilot/DRIFT_CHECKLIST.md +19 -0
- package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -0
- package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -0
- package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -0
- package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -0
- package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -0
- package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -0
- package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -0
- package/templates/rules.d/deukcontext-mcp.md +31 -0
- package/templates/rules.d/platform-coexistence.md +29 -0
- package/templates/skills/context-recall/SKILL.md +25 -0
- package/templates/skills/generated-file-guard/SKILL.md +25 -0
- package/templates/skills/project-pilot/SKILL.md +63 -0
- package/templates/skills/safe-refactor/SKILL.md +25 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, rmSync } from "fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
|
|
7
|
+
/** Converts an absolute path to a clickable file:/// URI */
|
|
8
|
+
export function toFileUri(absPath) {
|
|
9
|
+
return pathToFileURL(absPath).href;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const AGENT_ROOT_DIR = ".deuk-agent";
|
|
13
|
+
export const TICKET_SUBDIR = "tickets";
|
|
14
|
+
export const TEMPLATE_SUBDIR = "templates";
|
|
15
|
+
export const RULES_SUBDIR = "rules";
|
|
16
|
+
export const WORKFLOW_MODE_PLAN = "plan";
|
|
17
|
+
export const WORKFLOW_MODE_EXECUTE = "execute";
|
|
18
|
+
|
|
19
|
+
export const TICKET_DIR_NAME = `${AGENT_ROOT_DIR}/${TICKET_SUBDIR}`;
|
|
20
|
+
export const TICKET_INDEX_FILENAME = "INDEX.json";
|
|
21
|
+
export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
|
|
22
|
+
export const DOCS_SUBDIR = "docs";
|
|
23
|
+
export const PLANS_SUBDIR = "plan";
|
|
24
|
+
export const PLAN_LINKS_DIR = `${AGENT_ROOT_DIR}/${DOCS_SUBDIR}/${PLANS_SUBDIR}`;
|
|
25
|
+
|
|
26
|
+
export const LEGACY_TEMPLATE_DIR = ".deuk-agent-templates";
|
|
27
|
+
export const LEGACY_TICKET_DIR = ".deuk-agent-ticket";
|
|
28
|
+
export const LEGACY_TICKET_DIR_PLURAL = ".deuk-agent-tickets";
|
|
29
|
+
export const LEGACY_TICKET_DIR_ROOT = "ticket";
|
|
30
|
+
export const LEGACY_CONFIG_FILE = ".deuk-agent-rule.config.json";
|
|
31
|
+
export const LEGACY_IGNORE_DIR = `${AGENT_ROOT_DIR}/tickets/`;
|
|
32
|
+
export const ARCHIVE_YEAR_MONTH_RE = /^\d{4}-\d{2}$/;
|
|
33
|
+
export const ARCHIVE_DAY_RE = /^\d{2}$/;
|
|
34
|
+
|
|
35
|
+
const LEGACY_TICKET_GROUPS = new Set([
|
|
36
|
+
LEGACY_TICKET_DIR,
|
|
37
|
+
LEGACY_TICKET_DIR_PLURAL,
|
|
38
|
+
"ticket",
|
|
39
|
+
"tickets"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Computes the canonical repository-relative path for a ticket based on its state.
|
|
44
|
+
*/
|
|
45
|
+
export function computeTicketPath(entry) {
|
|
46
|
+
const isArchived = entry.status === "archived";
|
|
47
|
+
const group = normalizeTicketGroup(entry.group, "sub");
|
|
48
|
+
const fileStem = entry.fileName
|
|
49
|
+
? String(entry.fileName).replace(/\.md$/i, "")
|
|
50
|
+
: (entry.group === TICKET_SUBDIR && entry.topic ? entry.topic : entry.id);
|
|
51
|
+
|
|
52
|
+
if (!isArchived && group === TICKET_SUBDIR) {
|
|
53
|
+
return [TICKET_DIR_NAME, `${fileStem}.md`].join("/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isArchived && entry.archiveYearMonth) {
|
|
57
|
+
return [
|
|
58
|
+
TICKET_DIR_NAME,
|
|
59
|
+
"archive",
|
|
60
|
+
group,
|
|
61
|
+
entry.archiveYearMonth,
|
|
62
|
+
`${fileStem}.md`
|
|
63
|
+
].join("/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parts = [
|
|
67
|
+
TICKET_DIR_NAME,
|
|
68
|
+
isArchived ? "archive" : null,
|
|
69
|
+
group,
|
|
70
|
+
`${fileStem}.md`
|
|
71
|
+
].filter(Boolean);
|
|
72
|
+
return parts.join("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeTicketGroup(rawGroup, fallback = "sub") {
|
|
76
|
+
const value = String(rawGroup || "").trim();
|
|
77
|
+
if (!value) return fallback;
|
|
78
|
+
if (value.includes("/") || value.startsWith("TICKET-") && value.includes(".md")) return fallback;
|
|
79
|
+
if (value.endsWith(".md")) return fallback;
|
|
80
|
+
if (value.endsWith(".markdown")) return fallback;
|
|
81
|
+
if (LEGACY_TICKET_GROUPS.has(value)) return fallback;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const INIT_CONFIG_FILENAME = `${AGENT_ROOT_DIR}/config.json`;
|
|
86
|
+
export const INIT_CONFIG_VERSION = 1;
|
|
87
|
+
|
|
88
|
+
export const STACKS = [
|
|
89
|
+
{ label: "Unity / C#", value: "unity" },
|
|
90
|
+
{ label: "Unity + WebApp + C++ Server (Hybrid)", value: "unity-webapp-cpp" },
|
|
91
|
+
{ label: "Next.js + C#", value: "nextjs-dotnet" },
|
|
92
|
+
{ label: "Web (React / Vue / general)", value: "web" },
|
|
93
|
+
{ label: "Java / Spring Boot", value: "java" },
|
|
94
|
+
{ label: "Other / skip", value: "other" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export const AGENT_TOOLS = [
|
|
98
|
+
{ label: "Cursor (Rule System)", value: "cursor" },
|
|
99
|
+
{ label: "GitHub Copilot", value: "copilot" },
|
|
100
|
+
{ label: "Codex / OpenAI", value: "codex" },
|
|
101
|
+
{ label: "Gemini / Antigravity", value: "gemini" },
|
|
102
|
+
{ label: "Claude / Dev", value: "claude" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
export const SPOKE_REGISTRY = [
|
|
106
|
+
{ id: "cursor", detect: (cwd) => existsSync(join(cwd, ".cursor")), legacy: ".cursorrules", target: ".cursor/rules/deuk-agent.mdc", format: "mdc" },
|
|
107
|
+
{ id: "claude", detect: (cwd) => existsSync(join(cwd, "CLAUDE.md")) || existsSync(join(cwd, ".claude")), legacy: "CLAUDE.md", target: ".claude/rules/deuk-agent.md", format: "markdown" },
|
|
108
|
+
{ id: "copilot", detect: (cwd, tools = []) => tools.includes("copilot") || existsSync(join(cwd, ".github")), legacy: null, target: ".github/copilot-instructions.md", format: "markdown" },
|
|
109
|
+
{ id: "codex", detect: (cwd, tools = []) => tools.includes("codex") || existsSync(join(cwd, ".codex")), legacy: null, target: ".codex/AGENTS.md", format: "markdown" },
|
|
110
|
+
{ id: "windsurf", detect: (cwd) => existsSync(join(cwd, ".windsurf")), legacy: ".windsurfrules", target: ".windsurf/rules/deuk-agent.md", format: "markdown" },
|
|
111
|
+
{ id: "jetbrains", detect: (cwd) => existsSync(join(cwd, ".aiassistant")) || existsSync(join(cwd, ".idea")), legacy: null, target: ".aiassistant/rules/deuk-agent.md", format: "markdown" },
|
|
112
|
+
{ id: "antigravity", detect: (cwd, tools = []) => tools.includes("gemini") || existsSync(join(cwd, "GEMINI.md")) || existsSync(join(cwd, ".gemini")) || existsSync(join(cwd, ".mcp.json")), legacy: "GEMINI.md", target: "AGENTS.md", format: "markdown" }
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
export const DOC_LANGUAGE_CHOICES = [
|
|
116
|
+
{ label: "Auto (match system locale)", value: "auto" },
|
|
117
|
+
{ label: "Korean", value: "ko" },
|
|
118
|
+
{ label: "English", value: "en" },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export function normalizeDocsLanguage(value) {
|
|
122
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
123
|
+
if (!normalized || normalized === "auto") return "auto";
|
|
124
|
+
if (normalized.startsWith("ko") || normalized === "kr" || normalized === "korean") return "ko";
|
|
125
|
+
if (normalized.startsWith("en") || normalized === "english") return "en";
|
|
126
|
+
return "auto";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function inferDocsLanguageFromEnv(env = process.env) {
|
|
130
|
+
const locale = String(env.LANG || env.LC_ALL || env.LC_MESSAGES || "").toLowerCase();
|
|
131
|
+
if (locale.includes("ko")) return "ko";
|
|
132
|
+
return "en";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function inferDocsLanguageFromText(text) {
|
|
136
|
+
const src = String(text || "");
|
|
137
|
+
const hangulCount = (src.match(/[\u3131-\u318e\uac00-\ud7a3]/g) || []).length;
|
|
138
|
+
if (hangulCount >= 2) return "ko";
|
|
139
|
+
|
|
140
|
+
const latinWords = src.match(/[A-Za-z][A-Za-z'-]*/g) || [];
|
|
141
|
+
if (latinWords.length >= 2) return "en";
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveDocsLanguage(value, env = process.env) {
|
|
147
|
+
const normalized = normalizeDocsLanguage(value);
|
|
148
|
+
if (normalized === "auto") return inferDocsLanguageFromEnv(env);
|
|
149
|
+
return normalized;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function selectLocalizedTemplatePath(baseDir, templateName, docsLanguage = "en") {
|
|
153
|
+
const normalized = resolveDocsLanguage(docsLanguage);
|
|
154
|
+
const localizedName = `${templateName.replace(/\.md$/i, "")}.${normalized}.md`;
|
|
155
|
+
const localizedPath = join(baseDir, localizedName);
|
|
156
|
+
if (existsSync(localizedPath)) return localizedPath;
|
|
157
|
+
return join(baseDir, templateName);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function loadInitConfig(cwd) {
|
|
161
|
+
const p = join(cwd, INIT_CONFIG_FILENAME);
|
|
162
|
+
if (!existsSync(p)) return null;
|
|
163
|
+
|
|
164
|
+
let target = p;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const j = JSON.parse(readFileSync(target, "utf8"));
|
|
168
|
+
if (j.version !== INIT_CONFIG_VERSION) return null;
|
|
169
|
+
if (!j.docsLanguage) j.docsLanguage = "auto";
|
|
170
|
+
const workflowMode = normalizeWorkflowMode(j.workflowMode ?? j.approvalState);
|
|
171
|
+
j.workflowMode = workflowMode;
|
|
172
|
+
j.approvalState = workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending";
|
|
173
|
+
return j;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse config ${target}:`, err);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function writeInitConfig(cwd, opts) {
|
|
181
|
+
const p = join(cwd, INIT_CONFIG_FILENAME);
|
|
182
|
+
const dir = dirname(p);
|
|
183
|
+
if (!existsSync(dir)) {
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
const existing = loadInitConfig(cwd) || {};
|
|
187
|
+
const workflowMode = normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approvalState ?? opts.approval);
|
|
188
|
+
const data = {
|
|
189
|
+
version: INIT_CONFIG_VERSION,
|
|
190
|
+
agentsMode: opts.agents ?? existing.agentsMode ?? "inject",
|
|
191
|
+
workflowMode,
|
|
192
|
+
approvalState: workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending",
|
|
193
|
+
stack: opts.stack ?? existing.stack,
|
|
194
|
+
agentTools: opts.agentTools ?? existing.agentTools,
|
|
195
|
+
docsLanguage: normalizeDocsLanguage(opts.docsLanguage ?? existing.docsLanguage ?? "auto"),
|
|
196
|
+
shareTickets: opts.shareTickets ?? existing.shareTickets ?? false,
|
|
197
|
+
remoteSync: opts.remoteSync ?? existing.remoteSync ?? false,
|
|
198
|
+
pipelineUrl: opts.pipelineUrl,
|
|
199
|
+
ignoreDirs: opts.ignoreDirs || existing.ignoreDirs || DEFAULT_IGNORE_DIRS,
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function normalizeWorkflowMode(value) {
|
|
206
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
207
|
+
if (!normalized) return WORKFLOW_MODE_PLAN;
|
|
208
|
+
if (["execute", "approved", "approval", "apply", "apply-changes"].includes(normalized)) {
|
|
209
|
+
return WORKFLOW_MODE_EXECUTE;
|
|
210
|
+
}
|
|
211
|
+
if (["plan", "pending", "review", "prepare", "prepare-only"].includes(normalized)) {
|
|
212
|
+
return WORKFLOW_MODE_PLAN;
|
|
213
|
+
}
|
|
214
|
+
return normalized === WORKFLOW_MODE_EXECUTE ? WORKFLOW_MODE_EXECUTE : WORKFLOW_MODE_PLAN;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function isWorkflowExecute(opts = {}, savedConfig = null) {
|
|
218
|
+
return normalizeWorkflowMode(
|
|
219
|
+
opts.workflowMode ??
|
|
220
|
+
opts.workflow ??
|
|
221
|
+
opts.approval ??
|
|
222
|
+
opts.approvalState ??
|
|
223
|
+
savedConfig?.workflowMode ??
|
|
224
|
+
savedConfig?.approvalState
|
|
225
|
+
) === WORKFLOW_MODE_EXECUTE;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolves the final workflow mode by checking opts, then saved config, with fallback.
|
|
230
|
+
*/
|
|
231
|
+
export function resolveWorkflowMode(opts = {}, savedConfig = null) {
|
|
232
|
+
return normalizeWorkflowMode(
|
|
233
|
+
opts.workflowMode ??
|
|
234
|
+
opts.workflow ??
|
|
235
|
+
opts.approval ??
|
|
236
|
+
opts.approvalState ??
|
|
237
|
+
savedConfig?.workflowMode ??
|
|
238
|
+
savedConfig?.approvalState
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Strips dynamically appended rule modules from content.
|
|
244
|
+
*/
|
|
245
|
+
export function pruneRuleModules(content) {
|
|
246
|
+
const marker = "<!-- RULE MODULE: ";
|
|
247
|
+
const idx = content.indexOf(marker);
|
|
248
|
+
if (idx !== -1) {
|
|
249
|
+
return content.substring(0, idx).trimEnd();
|
|
250
|
+
}
|
|
251
|
+
return content.trimEnd();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Higher-order function to wrap interactive readline sessions.
|
|
256
|
+
*/
|
|
257
|
+
export async function withReadline(callback) {
|
|
258
|
+
if (!process.stdout.isTTY) {
|
|
259
|
+
throw new Error("Interactive terminal required.");
|
|
260
|
+
}
|
|
261
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
262
|
+
try {
|
|
263
|
+
return await callback(rl);
|
|
264
|
+
} finally {
|
|
265
|
+
rl.close();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function toPosixPath(p) {
|
|
270
|
+
return p.replace(/\\/g, "/");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function toRepoRelativePath(cwd, absPath) {
|
|
274
|
+
return toPosixPath(relative(cwd, absPath));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function toSlug(input) {
|
|
278
|
+
return String(input || "")
|
|
279
|
+
.toLowerCase()
|
|
280
|
+
.normalize("NFKD")
|
|
281
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
282
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
283
|
+
.replace(/^-+|-+$/g, "");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function requireNonEmptySlug(input, fieldName = "value") {
|
|
287
|
+
const slug = toSlug(input);
|
|
288
|
+
if (slug) return slug;
|
|
289
|
+
|
|
290
|
+
const received = String(input || "").trim() || "(empty)";
|
|
291
|
+
throw new Error(
|
|
292
|
+
`[VALIDATION FAILED] ${fieldName} must produce a non-empty ASCII slug. ` +
|
|
293
|
+
`Received: ${JSON.stringify(received)}. ` +
|
|
294
|
+
`Use an ASCII topic such as "basic-protocol-pack-unpack-test-redefinition".`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function toSnakeCaseKey(input) {
|
|
299
|
+
return toSlug(input).replace(/-/g, "_");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function formatTimestampForFile(d = new Date()) {
|
|
303
|
+
const y = d.getFullYear();
|
|
304
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
305
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
306
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
307
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
308
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
309
|
+
return `${y}${m}${day}-${hh}${mm}${ss}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function makeEntryId() {
|
|
313
|
+
return `000-generated-${Date.now().toString(36)}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function findFileRecursively(dir, fileName) {
|
|
317
|
+
if (!existsSync(dir)) return null;
|
|
318
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
const res = join(dir, entry.name);
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
const found = findFileRecursively(res, fileName);
|
|
323
|
+
if (found) return found;
|
|
324
|
+
} else if (entry.name === fileName) {
|
|
325
|
+
return res;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function detectProjectFromBody(body) {
|
|
332
|
+
const content = String(body || "");
|
|
333
|
+
const lines = content.split("\n");
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
const l = line.trim();
|
|
336
|
+
if (l.toLowerCase().startsWith("project:")) {
|
|
337
|
+
return l.split(":")[1].trim();
|
|
338
|
+
}
|
|
339
|
+
if (l.startsWith("# Project:") || l.startsWith("## Project:")) {
|
|
340
|
+
return l.split(":")[1].trim();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return "global";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function deriveTopicFromBaseName(baseName) {
|
|
347
|
+
const raw = String(baseName || "").split(".")[0];
|
|
348
|
+
// Remove trailing timestamp if present (e.g. topic-20260426-071208)
|
|
349
|
+
const parts = raw.split("-");
|
|
350
|
+
if (parts.length >= 3) {
|
|
351
|
+
const last = parts[parts.length - 1];
|
|
352
|
+
const prev = parts[parts.length - 2];
|
|
353
|
+
if (last.length === 6 && prev.length === 8) {
|
|
354
|
+
return parts.slice(0, -2).join("-");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return toSlug(raw) || raw.toLowerCase();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function resolveReferencedTicketPath(opts) {
|
|
361
|
+
if (!opts.ref) return null;
|
|
362
|
+
const refAbs = join(opts.cwd, opts.ref);
|
|
363
|
+
if (!existsSync(refAbs)) {
|
|
364
|
+
throw new Error("--ref file not found: " + opts.ref);
|
|
365
|
+
}
|
|
366
|
+
return toRepoRelativePath(opts.cwd, refAbs);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function inferRefTitleAndTopic(opts) {
|
|
370
|
+
if (!opts.ref) return null;
|
|
371
|
+
const refAbs = join(opts.cwd, opts.ref);
|
|
372
|
+
|
|
373
|
+
let body = "";
|
|
374
|
+
try {
|
|
375
|
+
body = readFileSync(refAbs, "utf8");
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read ref ${refAbs}:`, err);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const lines = body.split("\n");
|
|
382
|
+
let title = "";
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
const l = line.trim();
|
|
385
|
+
if (l.startsWith("## Task:")) {
|
|
386
|
+
title = l.replace("## Task:", "").trim();
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
if (l.startsWith("# ")) {
|
|
390
|
+
title = l.replace("# ", "").trim();
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const base = basename(refAbs).split(".")[0];
|
|
396
|
+
const finalTitle = title || base;
|
|
397
|
+
return {
|
|
398
|
+
title: String(finalTitle).trim(),
|
|
399
|
+
topic: toSlug(finalTitle),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function parseFrontMatter(content) {
|
|
404
|
+
const lines = content.split("\n");
|
|
405
|
+
if (lines.length < 3 || lines[0].trim() !== "---") {
|
|
406
|
+
return { meta: {}, content };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let endIdx = -1;
|
|
410
|
+
for (let i = 1; i < lines.length; i++) {
|
|
411
|
+
if (lines[i].trim() === "---") {
|
|
412
|
+
endIdx = i;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (endIdx === -1) return { meta: {}, content };
|
|
418
|
+
|
|
419
|
+
const metaStr = lines.slice(1, endIdx).join("\n");
|
|
420
|
+
const bodyStr = lines.slice(endIdx + 1).join("\n");
|
|
421
|
+
// Let YAML.parse throw if malformed (intentional behavior change to prevent data loss)
|
|
422
|
+
const meta = YAML.parse(metaStr);
|
|
423
|
+
return { meta: meta || {}, content: bodyStr };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function stringifyFrontMatter(meta, content) {
|
|
427
|
+
const cleanMeta = { ...meta };
|
|
428
|
+
// Remove redundant or default fields to keep frontmatter slim
|
|
429
|
+
delete cleanMeta.topic;
|
|
430
|
+
if (cleanMeta.project === 'global') delete cleanMeta.project;
|
|
431
|
+
if (!cleanMeta.submodule) delete cleanMeta.submodule;
|
|
432
|
+
|
|
433
|
+
// Normalize date format if it looks like an ISO string
|
|
434
|
+
if (typeof cleanMeta.createdAt === 'string' && cleanMeta.createdAt.includes('T')) {
|
|
435
|
+
cleanMeta.createdAt = cleanMeta.createdAt.replace('T', ' ').split('.')[0];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const yamlStr = YAML.stringify(cleanMeta).trim();
|
|
439
|
+
// Double newline after frontmatter to ensure markdown rendering integrity
|
|
440
|
+
return `---\n${yamlStr}\n---\n\n\n${content.trim()}\n`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns true if semver a is less than b
|
|
445
|
+
*/
|
|
446
|
+
export function semverLt(a, b) {
|
|
447
|
+
const pa = String(a || "0").replace(/[^0-9.]/g, "").split(".").map(Number);
|
|
448
|
+
const pb = String(b || "0").replace(/[^0-9.]/g, "").split(".").map(Number);
|
|
449
|
+
for (let i = 0; i < 3; i++) {
|
|
450
|
+
const na = pa[i] ?? 0, nb = pb[i] ?? 0;
|
|
451
|
+
if (na !== nb) return na < nb;
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function checkUpdateNotifier() {
|
|
457
|
+
try {
|
|
458
|
+
const { fileURLToPath } = await import("url");
|
|
459
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
460
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
461
|
+
const currentVersion = pkg.version;
|
|
462
|
+
const res = await fetch("https://registry.npmjs.org/deuk-agent-flow/latest", {
|
|
463
|
+
signal: AbortSignal.timeout(800)
|
|
464
|
+
});
|
|
465
|
+
if (res.ok) {
|
|
466
|
+
const data = await res.json();
|
|
467
|
+
// Only notify when registry version is strictly newer than local (handles local dev symlink case)
|
|
468
|
+
if (data.version && semverLt(currentVersion, data.version)) {
|
|
469
|
+
console.warn(`\n\x1b[33m💡 Update available! ${currentVersion} → ${data.version}\x1b[0m`);
|
|
470
|
+
console.warn(`\x1b[36mRun 'npm install -g deuk-agent-flow' to update.\x1b[0m\n`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch(e) {
|
|
474
|
+
// Ignore timeout or network errors silently
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export const DEFAULT_IGNORE_DIRS = ["node_modules", ".git", ".deuk-agent", "tmp", "temp", ".tmp", ".cache"];
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Resolves all potential ticket directories for a given path.
|
|
482
|
+
* Returns { primary, legacy: [] }
|
|
483
|
+
*/
|
|
484
|
+
export function resolveTicketSystemPaths(cwd) {
|
|
485
|
+
return {
|
|
486
|
+
primary: join(cwd, AGENT_ROOT_DIR, TICKET_SUBDIR),
|
|
487
|
+
legacy: []
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Detects the closest active ticket directory by traversing upwards.
|
|
493
|
+
*/
|
|
494
|
+
export function detectConsumerTicketDir(startDir, opts = {}) {
|
|
495
|
+
let curr = resolve(startDir);
|
|
496
|
+
while (curr && curr !== dirname(curr)) {
|
|
497
|
+
const paths = resolveTicketSystemPaths(curr);
|
|
498
|
+
if (existsSync(paths.primary)) return paths.primary;
|
|
499
|
+
if (paths.legacy.length > 0) return paths.legacy[0];
|
|
500
|
+
if (existsSync(join(curr, "AGENTS.md")) || existsSync(join(curr, "PROJECT_RULE.md"))) {
|
|
501
|
+
return opts.createIfMissing ? resolveTicketSystemPaths(curr).primary : null;
|
|
502
|
+
}
|
|
503
|
+
curr = dirname(curr);
|
|
504
|
+
}
|
|
505
|
+
return opts.createIfMissing ? resolveTicketSystemPaths(startDir).primary : null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function resolveConsumerTicketRoot(startDir, opts = {}) {
|
|
509
|
+
const ticketDir = detectConsumerTicketDir(startDir, opts);
|
|
510
|
+
if (!ticketDir) return null;
|
|
511
|
+
return dirname(dirname(ticketDir));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Unified workspace/submodule discovery.
|
|
516
|
+
*/
|
|
517
|
+
export function discoverAllWorkspaces(baseCwd, ignoreDirs = DEFAULT_IGNORE_DIRS, out = new Set()) {
|
|
518
|
+
if (!existsSync(baseCwd)) return Array.from(out);
|
|
519
|
+
|
|
520
|
+
const paths = resolveTicketSystemPaths(baseCwd);
|
|
521
|
+
if (existsSync(paths.primary) || paths.legacy.length > 0) {
|
|
522
|
+
out.add(baseCwd);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const entries = readdirSync(baseCwd, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
527
|
+
for (const ent of entries) {
|
|
528
|
+
if (!ent.isDirectory()) continue;
|
|
529
|
+
if (ignoreDirs.includes(ent.name) || ent.name.startsWith(".deuk-agent")) continue;
|
|
530
|
+
discoverAllWorkspaces(join(baseCwd, ent.name), ignoreDirs, out);
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to read directory ${baseCwd}:`, err);
|
|
534
|
+
}
|
|
535
|
+
return Array.from(out);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function probeMcpUrl(url) {
|
|
539
|
+
const methods = ["HEAD", "GET"];
|
|
540
|
+
|
|
541
|
+
for (const method of methods) {
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
const timeoutId = setTimeout(() => controller.abort(), 1000);
|
|
544
|
+
try {
|
|
545
|
+
const res = await fetch(url, { method, signal: controller.signal });
|
|
546
|
+
if (res.body?.cancel) await res.body.cancel().catch(() => {});
|
|
547
|
+
if (res.ok || res.status === 405) return true;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] SSE ${method} ping failed for ${url}:`, err);
|
|
550
|
+
} finally {
|
|
551
|
+
clearTimeout(timeoutId);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Checks if the deuk-agent-context MCP server is active for the given workspace.
|
|
560
|
+
* Detects .mcp.json, .cursor/mcp.json, or .vscode/mcp.json and pings SSE servers if applicable.
|
|
561
|
+
*/
|
|
562
|
+
export async function isMcpActive(cwd) {
|
|
563
|
+
const mcpPaths = [
|
|
564
|
+
join(cwd, ".mcp.json"),
|
|
565
|
+
join(cwd, ".cursor", "mcp.json"),
|
|
566
|
+
join(cwd, ".vscode", "mcp.json")
|
|
567
|
+
];
|
|
568
|
+
for (const p of mcpPaths) {
|
|
569
|
+
if (existsSync(p)) {
|
|
570
|
+
try {
|
|
571
|
+
const config = JSON.parse(readFileSync(p, "utf8"));
|
|
572
|
+
const servers = config.mcpServers || config.servers || {};
|
|
573
|
+
const deuk = servers["deuk-agent-context"] || servers["deuk_agent_context"];
|
|
574
|
+
if (deuk) {
|
|
575
|
+
if (deuk.command) return true; // Stdio is managed by IDE
|
|
576
|
+
if (deuk.url) {
|
|
577
|
+
return await probeMcpUrl(deuk.url);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse MCP config ${p}: ${err.message}`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|