deuk-agent-rule 2.2.2 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync, copyFileSync } from "fs";
2
2
  import { basename, dirname, join, relative } from "path";
3
- import { toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId, detectProjectFromBody, deriveTopicFromBaseName } from "./cli-utils.mjs";
3
+ import { createHash } from "crypto";
4
+ import { toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId, detectProjectFromBody, deriveTopicFromBaseName, parseFrontMatter, stringifyFrontMatter, loadInitConfig } from "./cli-utils.mjs";
4
5
 
5
6
  export const TICKET_DIR_NAME = ".deuk-agent-ticket";
6
7
  export const TICKET_INDEX_FILENAME = "INDEX.json";
@@ -65,34 +66,42 @@ export function writeTicketIndexJson(cwd, indexJson, opts = {}) {
65
66
 
66
67
  export function renderTicketListMarkdown(cwd, entries) {
67
68
  const sorted = [...entries].sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
68
- const latest = sorted[0] || null;
69
+ const latest = sorted.find(e => e.status !== "archived") || sorted[0] || null;
69
70
 
70
71
  const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
71
72
  const templatePath = join(ticketDir, TICKET_LIST_TEMPLATE_FILENAME);
72
73
  const template = existsSync(templatePath) ? readFileSync(templatePath, "utf8") : DEFAULT_TICKET_LIST_TEMPLATE;
73
74
 
74
- let latestBlock = "- No ticket entries yet.";
75
+ let latestBlock = "- No active ticket entries yet.";
75
76
  if (latest) {
76
77
  const relPath = toPosixPath(relative(ticketDir, join(cwd, latest.path)));
77
78
  const safeLatestTitle = String(latest.title || "").replace(/\[|\]/g, '').replace(/\n/g, ' ');
78
- latestBlock = `- [${safeLatestTitle}](${relPath})\n- group: \`${latest.group}\` / project: \`${latest.project}\` / created: \`${latest.createdAt}\``;
79
+ latestBlock = `- [${safeLatestTitle}](${relPath})\n- status: \`${latest.status}\` / group: \`${latest.group}\` / project: \`${latest.project}\``;
79
80
  }
80
81
 
81
- let rows = sorted.slice(0, 30).map((e, i) => {
82
- const relPath = toPosixPath(relative(ticketDir, join(cwd, e.path)));
83
- const statusPrefix = e.status === "closed" ? "✓ " : "";
84
- const safeTitle = String(e.title || "").replace(/\|/g, '|').replace(/(\n|\\n)+/g, ' ');
85
- return `| ${i + 1} | ${statusPrefix}${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt} | [open](${relPath}) |`;
86
- }).join("\n");
82
+ const activeRows = sorted.filter(e => e.status !== "archived").map((e, i) => renderLine(e, i, ticketDir, cwd));
83
+ const archivedRows = sorted.filter(e => e.status === "archived").slice(0, 50).map((e, i) => renderLine(e, i, ticketDir, cwd));
84
+
85
+ let combinedRows = "### 🚀 Active Tickets\n\n| # | Status | Title | Group | Project | Created | Path |\n|---|---|---|---|---|---|---|\n" +
86
+ (activeRows.join("\n") || "| - | - | No active tickets | - | - | - | - |") +
87
+ "\n\n### 📦 Archived Tickets\n\n| # | Status | Title | Group | Project | Created | Path |\n|---|---|---|---|---|---|---|\n" +
88
+ (archivedRows.join("\n") || "| - | - | No archived tickets | - | - | - | - |");
87
89
 
88
90
  return template
89
91
  .replaceAll("{{SOURCE_INDEX}}", `${TICKET_DIR_NAME}/${TICKET_INDEX_FILENAME}`)
90
92
  .replaceAll("{{LATEST_BLOCK}}", latestBlock)
91
- .replaceAll("{{ENTRIES_ROWS}}", rows || "| - | No entries yet | - | - | - | - |")
93
+ .replaceAll("{{ENTRIES_ROWS}}", combinedRows)
92
94
  .replaceAll("{{CMD_LIST}}", "npx deuk-agent-rule ticket list")
93
95
  .replaceAll("{{CMD_USE_LATEST}}", "npx deuk-agent-rule ticket use --latest");
94
96
  }
95
97
 
98
+ function renderLine(e, i, ticketDir, cwd) {
99
+ const relPath = toPosixPath(relative(ticketDir, join(cwd, e.path)));
100
+ const statusIcon = e.status === "active" ? "🔥 " : (e.status === "archived" ? "📦 " : "[ ] ");
101
+ const safeTitle = String(e.title || "").replace(/\|/g, '|').replace(/(\n|\\n)+/g, ' ');
102
+ return `| ${i + 1} | ${statusIcon}${e.status} | ${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt.split('T')[0]} | [open](${relPath}) |`;
103
+ }
104
+
96
105
  export function writeTicketListFile(cwd, entries, opts = {}) {
97
106
  const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
98
107
  const p = join(ticketDir, TICKET_LIST_FILENAME);
@@ -135,60 +144,243 @@ export function updateTicketEntryStatus(cwd, opts = {}) {
135
144
  return entry;
136
145
  }
137
146
 
147
+ export function performUpgradeMigration(cwd, opts = {}) {
148
+ const root = join(cwd, TICKET_DIR_NAME);
149
+ const archiveDir = join(root, "archive");
150
+
151
+ const files = collectTicketMarkdownFiles(root).filter(p => {
152
+ const base = basename(p);
153
+ return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME && base !== "ACTIVE_TICKET.md";
154
+ });
155
+
156
+ console.log(`[UPGRADE] Scanning ${files.length} tickets for V2 migration...`);
157
+
158
+ let count = 0;
159
+ for (const abs of files) {
160
+ const rel = toRepoRelativePath(cwd, abs);
161
+ const body = readFileSync(abs, "utf8");
162
+ const { meta, content } = parseFrontMatter(body);
163
+
164
+ if (meta.id && meta.status) {
165
+ // Already V2, but check if it needs archiving
166
+ const isAlreadyInArchive = rel.includes("/archive/");
167
+ if (meta.status === "archived" && !isAlreadyInArchive && !opts.dryRun) {
168
+ // Move to archive if status is archived but file is in root
169
+ moveFileToArchive(cwd, abs, meta.group || "sub");
170
+ }
171
+ continue;
172
+ }
173
+
174
+ // V1 -> V2 Migration
175
+ const titleMatch = content.match(/^##\s+Task:\s*(.+)$/m);
176
+ const title = meta.title || titleMatch?.[1]?.trim() || basename(abs).replace(/\.md$/i, "");
177
+
178
+ // Check if finished (all phases [x])
179
+ const phases = content.match(/\[[ x/]]/g);
180
+ const finished = phases && phases.length > 0 && phases.every(p => p.includes("x"));
181
+ const isAlreadyInArchive = rel.includes("/archive/");
182
+
183
+ let status = meta.status || "open";
184
+ if (finished || isAlreadyInArchive) {
185
+ status = "archived";
186
+ }
187
+
188
+ const project = meta.project || detectProjectFromBody(content);
189
+
190
+ const newMeta = {
191
+ id: meta.id || `ticket_${statSync(abs).mtimeMs}`,
192
+ title,
193
+ status,
194
+ submodule: meta.submodule || (content.includes("DeukPack") ? "DeukPack" : ""),
195
+ project,
196
+ createdAt: meta.createdAt || statSync(abs).birthtime.toISOString(),
197
+ updatedAt: new Date().toISOString()
198
+ };
199
+
200
+ const migratedBody = stringifyFrontMatter(newMeta, content);
201
+
202
+ if (opts.dryRun) {
203
+ console.log(`[DRY-RUN] Would upgrade: ${rel} (status: ${status})`);
204
+ } else {
205
+ let finalAbs = abs;
206
+ if (status === "archived" && !isAlreadyInArchive) {
207
+ finalAbs = moveFileToArchive(cwd, abs, basename(dirname(abs)));
208
+ }
209
+ writeFileSync(finalAbs, migratedBody, "utf8");
210
+ console.log(`[OK] Upgraded: ${toRepoRelativePath(cwd, finalAbs)}`);
211
+ count++;
212
+ }
213
+ }
214
+
215
+ if (!opts.dryRun) {
216
+ rebuildTicketIndexFromTopicFilesIfNeeded(cwd, { ...opts, force: true });
217
+ performDefragmentation(cwd, opts); // NEW: Split to submodules
218
+ syncActiveTicketPointer(cwd);
219
+ }
220
+
221
+ return count;
222
+ }
223
+
224
+ export function performDefragmentation(cwd, opts = {}) {
225
+ const rootTicketDir = join(cwd, TICKET_DIR_NAME);
226
+ const tickets = collectTicketMarkdownFiles(rootTicketDir).filter(p => {
227
+ const base = basename(p);
228
+ return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME && base !== "ACTIVE_TICKET.md";
229
+ });
230
+
231
+ console.log(`[DEFRAG] Checking ${tickets.length} tickets for submodule placement...`);
232
+
233
+ const modifiedSubmodules = new Set();
234
+
235
+ for (const abs of tickets) {
236
+ const { meta } = parseFrontMatter(readFileSync(abs, "utf8"));
237
+ if (meta.submodule && meta.submodule !== "global") {
238
+ const subPath = join(cwd, meta.submodule);
239
+ if (existsSync(subPath) && statSync(subPath).isDirectory()) {
240
+ const subTicketDir = join(subPath, TICKET_DIR_NAME);
241
+ mkdirSync(subTicketDir, { recursive: true });
242
+
243
+ const relToRoot = relative(rootTicketDir, abs);
244
+ const destAbs = join(subTicketDir, relToRoot);
245
+
246
+ if (opts.dryRun) {
247
+ console.log(`[DRY-RUN] Would move to submodule: ${relToRoot} -> ${meta.submodule}/${TICKET_DIR_NAME}/`);
248
+ } else {
249
+ mkdirSync(dirname(destAbs), { recursive: true });
250
+ copyFileSync(abs, destAbs);
251
+ unlinkSync(abs);
252
+ console.log(`[DEFRAG] Moved: ${meta.submodule}/${TICKET_DIR_NAME}/${relToRoot}`);
253
+ modifiedSubmodules.add(subPath);
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ // Re-index all touched submodules
260
+ if (!opts.dryRun) {
261
+ for (const subCwd of modifiedSubmodules) {
262
+ rebuildTicketIndexFromTopicFilesIfNeeded(subCwd, { ...opts, force: true });
263
+ syncActiveTicketPointer(subCwd);
264
+ }
265
+ }
266
+ }
267
+
268
+ function moveFileToArchive(cwd, abs, group) {
269
+ const archiveBase = join(cwd, TICKET_DIR_NAME, "archive");
270
+ const targetSubDir = (group === TICKET_DIR_NAME || !group) ? "sub" : group;
271
+ const targetDir = join(archiveBase, targetSubDir);
272
+ mkdirSync(targetDir, { recursive: true });
273
+ const finalAbs = join(targetDir, basename(abs));
274
+ if (finalAbs !== abs) {
275
+ if (existsSync(finalAbs)) {
276
+ unlinkSync(abs); // Already exists in archive
277
+ } else {
278
+ writeFileSync(finalAbs, readFileSync(abs, "utf8"), "utf8");
279
+ unlinkSync(abs);
280
+ }
281
+ }
282
+ return finalAbs;
283
+ }
284
+
138
285
  export function collectTicketMarkdownFiles(dir, out = []) {
139
286
  if (!existsSync(dir)) return out;
140
287
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
141
288
  const abs = join(dir, ent.name);
289
+ // Ignore common noise
290
+ if (ent.name === "node_modules" || ent.name === ".git") continue;
291
+
142
292
  if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
143
- else if (ent.isFile() && /\.md$/i.test(ent.name)) out.push(abs);
293
+ else if (ent.isFile() && /\.md$/i.test(ent.name)) {
294
+ const base = ent.name;
295
+ if (base === "LATEST.md" || base === TICKET_LIST_FILENAME || base === TICKET_LIST_TEMPLATE_FILENAME || base === "ACTIVE_TICKET.md") continue;
296
+ out.push(abs);
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * Finds all .deuk-agent-ticket directories recursively, skipping node_modules/.git
304
+ */
305
+ export function discoverAllTicketDirs(baseCwd, out = []) {
306
+ if (!existsSync(baseCwd)) return out;
307
+ const entries = readdirSync(baseCwd, { withFileTypes: true });
308
+
309
+ // If current dir has .deuk-agent-ticket, add it
310
+ const local = join(baseCwd, TICKET_DIR_NAME);
311
+ if (existsSync(local) && statSync(local).isDirectory()) {
312
+ out.push(local);
313
+ }
314
+
315
+ for (const ent of entries) {
316
+ if (!ent.isDirectory()) continue;
317
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === TICKET_DIR_NAME) continue;
318
+ discoverAllTicketDirs(join(baseCwd, ent.name), out);
144
319
  }
145
320
  return out;
146
321
  }
147
322
 
148
323
  export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
149
324
  const indexJson = readTicketIndexJson(cwd);
150
- const root = join(cwd, TICKET_DIR_NAME);
151
- const files = collectTicketMarkdownFiles(root).filter(p => {
152
- const base = basename(p);
153
- return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME;
154
- });
325
+ // Hierarchical Scan: If we are at root, discover all sub-dirs.
326
+ const isRoot = existsSync(join(cwd, "DeukAgentRules")) || existsSync(join(cwd, "project_i"));
327
+
328
+ let ticketDirs = [];
329
+ if (opts.recursive !== false && isRoot) {
330
+ ticketDirs = discoverAllTicketDirs(cwd);
331
+ } else {
332
+ const local = join(cwd, TICKET_DIR_NAME);
333
+ if (existsSync(local) && statSync(local).isDirectory()) {
334
+ ticketDirs = [local];
335
+ }
336
+ }
337
+
338
+ if (ticketDirs.length === 0) return indexJson;
339
+
340
+ const files = [];
341
+ for (const dir of ticketDirs) {
342
+ collectTicketMarkdownFiles(dir, files);
343
+ }
155
344
 
156
345
  let dirty = false;
157
- const existingPaths = new Set(indexJson.entries.map(e => toPosixPath(e.path)));
346
+ const newEntries = [];
158
347
 
159
348
  for (let i = 0; i < files.length; i++) {
160
349
  const abs = files[i];
161
350
  const rel = toPosixPath(toRepoRelativePath(cwd, abs));
162
- if (!existingPaths.has(rel)) {
163
- const body = readFileSync(abs, "utf8");
164
- const titleMatch = body.match(/^##\s+Task:\s*(.+)$/m);
165
- const title = titleMatch ? titleMatch[1].trim() : basename(abs).replace(/\.md$/i, "");
166
- indexJson.entries.push({
167
- id: `ticket_recovered_${Date.now()}_${i}`,
168
- title,
169
- topic: deriveTopicFromBaseName(basename(abs)),
170
- group: basename(dirname(abs)),
171
- project: detectProjectFromBody(body),
172
- createdAt: statSync(abs).mtime.toISOString(),
173
- path: rel,
174
- source: "ticket-recover-scan",
175
- status: "open",
176
- });
177
- dirty = true;
178
- }
351
+ const body = readFileSync(abs, "utf8");
352
+ const { meta, content } = parseFrontMatter(body);
353
+ const titleMatch = content.match(/^##\s+Task:\s*(.+)$/m);
354
+
355
+ const title = meta.title || titleMatch?.[1]?.trim() || basename(abs).replace(/\.md$/i, "");
356
+ const isAlreadyInArchive = rel.includes("/archive/");
357
+ const status = isAlreadyInArchive ? "archived" : (meta.status || "open");
358
+ const project = meta.project || detectProjectFromBody(content);
359
+ const submodule = meta.submodule || "";
360
+
361
+ newEntries.push({
362
+ id: meta.id || makeEntryId(),
363
+ title,
364
+ topic: deriveTopicFromBaseName(basename(abs)),
365
+ group: basename(dirname(abs)),
366
+ project,
367
+ submodule: meta.submodule || (rel.startsWith(TICKET_DIR_NAME) ? "" : rel.split("/")[0]),
368
+ createdAt: meta.createdAt || statSync(abs).mtime.toISOString(),
369
+ updatedAt: meta.updatedAt || statSync(abs).mtime.toISOString(),
370
+ path: rel,
371
+ source: "ticket-sync",
372
+ status,
373
+ });
179
374
  }
180
375
 
181
- const physicalPaths = new Set(files.map(abs => toPosixPath(toRepoRelativePath(cwd, abs))));
182
- const originalLength = indexJson.entries.length;
183
- indexJson.entries = indexJson.entries.filter(e => physicalPaths.has(toPosixPath(e.path)));
184
-
185
- if (indexJson.entries.length !== originalLength) {
376
+ // Compare with old index to see if dirty
377
+ if (JSON.stringify(indexJson.entries) !== JSON.stringify(newEntries)) {
186
378
  dirty = true;
187
379
  }
188
380
 
189
- if (dirty) {
190
- indexJson.entries.sort((a,b) => String(b.createdAt||"").localeCompare(String(a.createdAt||"")));
191
- const next = { version: 1, updatedAt: new Date().toISOString(), entries: indexJson.entries };
381
+ if (dirty || opts.force) {
382
+ newEntries.sort((a,b) => String(b.createdAt||"").localeCompare(String(a.createdAt||"")));
383
+ const next = { version: 1, updatedAt: new Date().toISOString(), entries: newEntries };
192
384
  writeTicketIndexJson(cwd, next, opts);
193
385
  writeTicketListFile(cwd, next.entries, opts);
194
386
  return next;
@@ -211,7 +403,7 @@ export function parseLegacyTicketMeta(legacyBody) {
211
403
  }
212
404
 
213
405
  export function getLegacyMigrationCandidate(cwd) {
214
- const candidateFiles = ["LATEST.md", "implementation_plan.md"];
406
+ const candidateFiles = ["LATEST.md"];
215
407
  const candidateDirs = [cwd, join(cwd, TICKET_DIR_NAME), join(cwd, "ticket")];
216
408
  for (const dir of candidateDirs) {
217
409
  for (const file of candidateFiles) {
@@ -219,7 +411,7 @@ export function getLegacyMigrationCandidate(cwd) {
219
411
  if (existsSync(p)) {
220
412
  const body = readFileSync(p, "utf8");
221
413
  // Check for common plan or task markers
222
- if (body.length > 50 && (/^##\s+Task:/m.test(body) || /^#\s+Plan:/m.test(body) || /^#\s+Implementation Plan:/m.test(body))) {
414
+ if (body.length > 50 && (/^##\s+Task:/m.test(body) || /^#\s+Plan:/m.test(body))) {
223
415
  return { latestPath: p, body };
224
416
  }
225
417
  }
@@ -227,3 +419,87 @@ export function getLegacyMigrationCandidate(cwd) {
227
419
  }
228
420
  return null;
229
421
  }
422
+
423
+ export function syncActiveTicketPointer(cwd) {
424
+ const index = readTicketIndexJson(cwd);
425
+ // Find the single "active" ticket, or the most recent "open" ticket.
426
+ const activeEntry = index.entries.find(e => e.status === "active") ||
427
+ index.entries.find(e => e.status === "open");
428
+
429
+ const ticketDir = detectConsumerTicketDir(cwd);
430
+ if (!ticketDir) return;
431
+
432
+ // LATEST.md is deprecated. Remove it on every sync to prevent stale reads.
433
+ const legacyLatestPath = join(ticketDir, "LATEST.md");
434
+ if (existsSync(legacyLatestPath)) {
435
+ unlinkSync(legacyLatestPath);
436
+ }
437
+
438
+ const pointerPathMd = join(ticketDir, "ACTIVE_TICKET.md");
439
+ const pointerPathJson = join(ticketDir, "ACTIVE_TICKET.json");
440
+
441
+ if (activeEntry) {
442
+ const srcAbs = join(cwd, activeEntry.path);
443
+ if (existsSync(srcAbs)) {
444
+ const redirectBody = `# 🚀 Active Ticket Redirect\n\n> **[STOP] DO NOT EDIT THIS FILE!**\n> This is a pointer file. Editing this file will result in data loss because it is not the original ticket.\n\n**Please open and edit the original ticket here:**\n🔗 [${basename(activeEntry.path)}](/${toPosixPath(activeEntry.path)})\n\n---\n*Original Path:* \`${activeEntry.path}\`\n*Topic:* \`${activeEntry.topic}\``;
445
+ const redirectContent = stringifyFrontMatter({
446
+ id: activeEntry.id || "pointer",
447
+ title: activeEntry.title || "ACTIVE_TICKET_POINTER",
448
+ topic: activeEntry.topic || "",
449
+ status: activeEntry.status || "active",
450
+ submodule: activeEntry.submodule || "",
451
+ project: activeEntry.project || "",
452
+ createdAt: activeEntry.createdAt || new Date().toISOString()
453
+ }, redirectBody);
454
+ writeFileSync(pointerPathMd, redirectContent, "utf8");
455
+ writeFileSync(pointerPathJson, JSON.stringify({ ...activeEntry, syncedAt: new Date().toISOString() }, null, 2), "utf8");
456
+
457
+ // Hook: Optional background sync to remote pipeline
458
+ const config = loadInitConfig(cwd);
459
+ if (config && config.remoteSync && config.pipelineUrl) {
460
+ syncToPipeline(config.pipelineUrl, { action: "sync_active", ticket: activeEntry });
461
+ }
462
+ return;
463
+ }
464
+ }
465
+
466
+ // If no active ticket, clear pointers
467
+ const noTicketMsg = "# No Active Ticket Found\nUse `npx deuk-agent-rule ticket list` to find open tasks.\n";
468
+ writeFileSync(pointerPathMd, noTicketMsg, "utf8");
469
+ writeFileSync(pointerPathJson, JSON.stringify({ status: "none", message: "No active ticket" }), "utf8");
470
+ }
471
+
472
+
473
+ /**
474
+ * Deterministic or Hash-based ID to prevent collisions in multi-device sync
475
+ */
476
+ export function generateTicketId(title) {
477
+ const seed = `${title}-${Date.now()}-${Math.random()}`;
478
+ try {
479
+ return "ticket_" + createHash("md5").update(seed).digest("hex").slice(0, 12);
480
+ } catch {
481
+ return "ticket_" + Date.now() + "_" + Math.floor(Math.random() * 1000);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Async background sync to AI Pipeline.
487
+ * Returning true on success, false on failure (for connect check).
488
+ */
489
+ export async function syncToPipeline(url, data) {
490
+ if (typeof fetch === "undefined") {
491
+ // Node.js version < 18 or no fetch polyfill
492
+ return false;
493
+ }
494
+ try {
495
+ const response = await fetch(url, {
496
+ method: "POST",
497
+ headers: { "Content-Type": "application/json" },
498
+ body: JSON.stringify(data),
499
+ signal: AbortSignal?.timeout ? AbortSignal.timeout(3000) : undefined
500
+ });
501
+ return response.ok;
502
+ } catch (err) {
503
+ return false;
504
+ }
505
+ }
@@ -1,5 +1,51 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { basename, dirname, join, relative } from "path";
3
+ import YAML from "yaml";
4
+
5
+ export const INIT_CONFIG_FILENAME = ".deuk-agent-rule.config.json";
6
+ export const INIT_CONFIG_VERSION = 1;
7
+
8
+ export const STACKS = [
9
+ { label: "Unity / C#", value: "unity" },
10
+ { label: "Unity + WebApp + C++ Server (Hybrid)", value: "unity-webapp-cpp" },
11
+ { label: "Next.js + C#", value: "nextjs-dotnet" },
12
+ { label: "Web (React / Vue / general)", value: "web" },
13
+ { label: "Java / Spring Boot", value: "java" },
14
+ { label: "Other / skip", value: "other" },
15
+ ];
16
+
17
+ export const AGENT_TOOLS = [
18
+ { label: "Cursor (Rule System)", value: "cursor" },
19
+ { label: "Gemini / Antigravity", value: "gemini" },
20
+ { label: "Claude / Dev", value: "claude" },
21
+ ];
22
+
23
+ export function loadInitConfig(cwd) {
24
+ const p = join(cwd, INIT_CONFIG_FILENAME);
25
+ if (!existsSync(p)) return null;
26
+ try {
27
+ const j = JSON.parse(readFileSync(p, "utf8"));
28
+ if (j.version !== INIT_CONFIG_VERSION) return null;
29
+ return j;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function writeInitConfig(cwd, opts) {
36
+ const p = join(cwd, INIT_CONFIG_FILENAME);
37
+ const data = {
38
+ version: INIT_CONFIG_VERSION,
39
+ agentsMode: opts.agents || "inject",
40
+ stack: opts.stack,
41
+ agentTools: opts.agentTools,
42
+ shareTickets: !!opts.shareTickets,
43
+ remoteSync: !!opts.remoteSync,
44
+ pipelineUrl: opts.pipelineUrl,
45
+ updatedAt: new Date().toISOString(),
46
+ };
47
+ writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
48
+ }
3
49
 
4
50
  export function toPosixPath(p) {
5
51
  return p.replace(/\\/g, "/");
@@ -80,3 +126,20 @@ export function inferRefTitleAndTopic(opts) {
80
126
  topic,
81
127
  };
82
128
  }
129
+
130
+ export function parseFrontMatter(content) {
131
+ const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n?([\s\S]*)$/);
132
+ if (!match) return { meta: {}, content };
133
+ try {
134
+ const meta = YAML.parse(match[1]);
135
+ return { meta: meta || {}, content: match[2] };
136
+ } catch (e) {
137
+ console.error("YAML Parse Error:", e);
138
+ return { meta: {}, content };
139
+ }
140
+ }
141
+
142
+ export function stringifyFrontMatter(meta, content) {
143
+ const yamlStr = YAML.stringify(meta).trim();
144
+ return `---\n${yamlStr}\n---\n\n${content.trim()}\n`;
145
+ }
package/scripts/cli.mjs CHANGED
@@ -4,8 +4,9 @@ import { dirname, join } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { parseArgs, parseTicketArgs } from "./cli-args.mjs";
6
6
  import { runInit, runMerge } from "./cli-init-commands.mjs";
7
- import { runTicketCreate, runTicketList, runTicketUse, runTicketClose, runTicketArchive, runTicketReports } from "./cli-ticket-commands.mjs";
8
- import { loadInitConfig, writeInitConfig } from "./cli-prompts.mjs";
7
+ import { runTicketCreate, runTicketList, runTicketUse, runTicketClose, runTicketArchive, runTicketReports, runTicketMeta, runTicketConnect } from "./cli-ticket-commands.mjs";
8
+ import { performUpgradeMigration } from "./cli-ticket-logic.mjs";
9
+ import { loadInitConfig, writeInitConfig } from "./cli-utils.mjs";
9
10
  import { runInteractive } from "./cli-prompts.mjs";
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -30,7 +31,12 @@ async function main() {
30
31
  else if (action === "close") await runTicketClose(opts);
31
32
  else if (action === "archive") await runTicketArchive(opts);
32
33
  else if (action === "reports") await runTicketReports(opts);
33
- else if (action === "migrate") await runTicketMigrate(opts);
34
+ else if (action === "meta") await runTicketMeta(opts);
35
+ else if (action === "connect") await runTicketConnect(opts);
36
+ else if (action === "upgrade" || action === "migrate") {
37
+ const count = performUpgradeMigration(opts.cwd, opts);
38
+ console.log(`Migration complete: ${count} tickets upgraded.`);
39
+ }
34
40
  else {
35
41
  console.error("Unknown ticket action: " + action);
36
42
  printHelp();
@@ -66,7 +72,7 @@ async function main() {
66
72
  printHelp();
67
73
  }
68
74
 
69
- import { runTicketMigrate } from "./cli-ticket-commands.mjs";
75
+ // Removed legacy migration runTicketMigrate
70
76
 
71
77
  async function handleInit(opts) {
72
78
  if (!opts.interactive && !opts.nonInteractive && !loadInitConfig(opts.cwd)) {
@@ -86,7 +92,7 @@ function printHelp() {
86
92
  Usage:
87
93
  npx deuk-agent-rule init [options]
88
94
  npx deuk-agent-rule merge [options]
89
- npx deuk-agent-rule ticket <create|list|use|close|archive|reports|migrate> [options]
95
+ npx deuk-agent-rule ticket <create|list|use|close|archive|reports|migrate|upgrade|meta|connect> [options]
90
96
 
91
97
  Options:
92
98
  --cwd <path> Target repo root
@@ -96,13 +102,19 @@ Options:
96
102
  --agents <mode> inject | skip | overwrite
97
103
  --rules <mode> prefix | skip | overwrite
98
104
  --cursorrules <mode> inject | skip | overwrite
105
+ --json Output result in JSON format
106
+ --remote <url> Temporary pipeline URL
107
+ --sync Force enable remote sync
108
+ --no-sync Force disable remote sync
99
109
 
100
110
  Ticket Options:
101
111
  --topic <name> Ticket topic slug
102
112
  --group <name> Ticket group (sub|main|discussion)
103
113
  --project <name> Project filter (DeukUI|DeukAgentRules)
104
- --latest Use most recent ticket
114
+ --submodule <name> Submodule filter (DeukPack|DeukUI)
115
+ --latest Use most recent ticket (default if no topic)
105
116
  --path-only Print only the file path
117
+ --json Output result in JSON format
106
118
  `);
107
119
  }
108
120
 
@@ -49,6 +49,9 @@ cpSync(join(pkgRoot, "README.ko.md"), join(ossRoot, "README.ko.md"), { force: tr
49
49
  if (existsSync(join(pkgRoot, "CHANGELOG.md"))) {
50
50
  cpSync(join(pkgRoot, "CHANGELOG.md"), join(ossRoot, "CHANGELOG.md"), { force: true });
51
51
  }
52
+ if (existsSync(join(pkgRoot, "CHANGELOG.ko.md"))) {
53
+ cpSync(join(pkgRoot, "CHANGELOG.ko.md"), join(ossRoot, "CHANGELOG.ko.md"), { force: true });
54
+ }
52
55
  if (existsSync(join(pkgRoot, "package-lock.json"))) {
53
56
  cpSync(join(pkgRoot, "package-lock.json"), join(ossRoot, "package-lock.json"), { force: true });
54
57
  }
@@ -91,6 +94,7 @@ const outPkg = {
91
94
  "README.md",
92
95
  "README.ko.md",
93
96
  "CHANGELOG.md",
97
+ "CHANGELOG.ko.md",
94
98
  ],
95
99
  };
96
100
  delete outPkg.private;
@@ -119,4 +123,4 @@ if (existsSync(ossPolish)) {
119
123
  }
120
124
 
121
125
  console.log("deuk-agent-rule: synced OSS tree at " + ossRoot);
122
- console.log(" Override repo URL: DEUK_AGENT_RULES_OSS_REPO=https://github.com/org/DeukAgentRulesOSS");
126
+ console.log(" Override repo URL: DEUK_AGENT_RULES_OSS_REPO=https://github.com/joygram/DeukAgentRules");