deuk-agent-rule 2.4.6 → 2.5.13

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,8 +1,15 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, readdirSync, rmSync, statSync } from "fs";
2
2
  import { hostname } from "os";
3
3
  import { basename, join, dirname, relative, resolve } from "path";
4
- import { toSlug, toRepoRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter } from "./cli-utils.mjs";
5
- import { TICKET_DIR_NAME, appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, detectConsumerTicketDir, readTicketIndexJson, writeTicketIndexJson, writeTicketListFile, syncActiveTicketPointer, generateTicketId, syncToPipeline } from "./cli-ticket-logic.mjs";
4
+ import {
5
+ toSlug, toRepoRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter,
6
+ AGENT_ROOT_DIR, TICKET_SUBDIR, TEMPLATE_SUBDIR, TICKET_DIR_NAME
7
+ } from "./cli-utils.mjs";
8
+ import {
9
+ appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, detectConsumerTicketDir,
10
+ readTicketIndexJson, writeTicketIndexJson, writeTicketListFile, syncActiveTicketId,
11
+ generateTicketId, syncToPipeline, updateTicketEntryStatus
12
+ } from "./cli-ticket-logic.mjs";
6
13
  import { loadInitConfig } from "./cli-utils.mjs";
7
14
  import ejs from "ejs";
8
15
 
@@ -23,12 +30,20 @@ export async function runTicketCreate(opts) {
23
30
  source = "ticket-reference";
24
31
  } else {
25
32
  let tplText = "";
26
- const consumerTplPath = join(opts.cwd, ".deuk-agent-templates", "TICKET_TEMPLATE.md");
33
+ const consumerTplPath = join(opts.cwd, AGENT_ROOT_DIR, TEMPLATE_SUBDIR, "TICKET_TEMPLATE.md");
34
+ const legacyTplPath = join(opts.cwd, ".deuk-agent-templates", "TICKET_TEMPLATE.md");
27
35
  const bundleTplPath = join(new URL('.', import.meta.url).pathname, "..", "bundle", "templates", "TICKET_TEMPLATE.md");
28
36
 
29
37
  if (existsSync(consumerTplPath)) tplText = readFileSync(consumerTplPath, "utf8");
38
+ else if (existsSync(legacyTplPath)) tplText = readFileSync(legacyTplPath, "utf8");
30
39
  else if (existsSync(bundleTplPath)) tplText = readFileSync(bundleTplPath, "utf8");
31
- else throw new Error("ticket create: Template not found. Refusing to create an empty ticket.");
40
+ else throw new Error("ticket create: Template not found. Please run 'npx deuk-agent-rule init' to deploy templates.");
41
+
42
+ let planTplText = "";
43
+ const consumerPlanTplPath = join(opts.cwd, AGENT_ROOT_DIR, TEMPLATE_SUBDIR, "PLAN_TEMPLATE.md");
44
+ const bundlePlanTplPath = join(new URL('.', import.meta.url).pathname, "..", "bundle", "templates", "PLAN_TEMPLATE.md");
45
+ if (existsSync(consumerPlanTplPath)) planTplText = readFileSync(consumerPlanTplPath, "utf8");
46
+ else if (existsSync(bundlePlanTplPath)) planTplText = readFileSync(bundlePlanTplPath, "utf8");
32
47
 
33
48
  // Find nearest or create in CWD if missing
34
49
  const ticketDir = detectConsumerTicketDir(opts.cwd, { createIfMissing: true });
@@ -41,6 +56,12 @@ export async function runTicketCreate(opts) {
41
56
  mkdirSync(join(ticketDir, group), { recursive: true });
42
57
  path = toRepoRelativePath(opts.cwd, abs);
43
58
 
59
+ // Auto-Scaffold Plan Document
60
+ const plansDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plans");
61
+ const planFileName = `${ticketId}-plan.md`;
62
+ const planAbs = join(plansDir, planFileName);
63
+ const planLink = `[${planFileName}](file://${planAbs})`;
64
+
44
65
  const meta = {
45
66
  id: ticketId,
46
67
  title,
@@ -48,6 +69,7 @@ export async function runTicketCreate(opts) {
48
69
  status: "open",
49
70
  submodule: opts.submodule || "",
50
71
  project: opts.project || "global",
72
+ planLink: planLink,
51
73
  createdAt: new Date().toISOString(),
52
74
  };
53
75
 
@@ -66,6 +88,14 @@ createdAt: <%= meta.createdAt %>
66
88
  writeFileSync(abs, finalContent, "utf8");
67
89
  source = "ticket-create";
68
90
 
91
+ // Write Plan Document
92
+ if (planTplText) {
93
+ mkdirSync(plansDir, { recursive: true });
94
+ const planContent = ejs.render(planTplText, { meta });
95
+ writeFileSync(planAbs, planContent, "utf8");
96
+ console.log(`Plan scaffolded: ${toRepoRelativePath(opts.cwd, planAbs)}`);
97
+ }
98
+
69
99
  // Remote Sync Hook
70
100
  const config = loadInitConfig(opts.cwd);
71
101
  if (config && config.remoteSync && config.pipelineUrl) {
@@ -79,7 +109,7 @@ createdAt: <%= meta.createdAt %>
79
109
  }, opts);
80
110
  }
81
111
 
82
- syncActiveTicketPointer(opts.cwd);
112
+ syncActiveTicketId(opts.cwd);
83
113
  }
84
114
 
85
115
  export async function runTicketList(opts) {
@@ -88,7 +118,7 @@ export async function runTicketList(opts) {
88
118
  throw new Error("No ticket system found. Please run 'npx deuk-agent-rule init' first.");
89
119
  }
90
120
  const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
91
- syncActiveTicketPointer(opts.cwd);
121
+ syncActiveTicketId(opts.cwd);
92
122
  let rows = index.entries;
93
123
 
94
124
 
@@ -146,7 +176,6 @@ export async function runTicketConnect(opts) {
146
176
  }
147
177
  }
148
178
 
149
- import { updateTicketEntryStatus } from "./cli-ticket-logic.mjs";
150
179
 
151
180
  export async function runTicketClose(opts) {
152
181
  if (!opts.topic && !opts.latest) {
@@ -171,12 +200,13 @@ export async function runTicketClose(opts) {
171
200
  }
172
201
  opts.status = "closed";
173
202
  const entry = updateTicketEntryStatus(opts.cwd, opts);
174
- syncActiveTicketPointer(opts.cwd);
203
+ syncActiveTicketId(opts.cwd);
175
204
  console.log(`ticket: closed -> ${entry.topic} (${entry.path})`);
176
205
  }
177
206
 
178
207
  export async function runTicketUse(opts) {
179
208
  const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
209
+ syncActiveTicketId(opts.cwd);
180
210
 
181
211
  let targetTopic = opts.topic;
182
212
  if (!targetTopic && !opts.latest) {
@@ -217,15 +247,15 @@ export function pickTicketEntry(opts, indexJson) {
217
247
  }
218
248
 
219
249
  export async function runTicketArchive(opts) {
250
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
251
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
252
+
220
253
  if (!opts.latest && !opts.topic) {
221
254
  if (process.stdout.isTTY) {
222
- const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
223
- const choices = index.entries
255
+ const choices = indexJson.entries
224
256
  .filter(e => e.status !== "archived")
225
257
  .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
226
258
  if (choices.length > 0) {
227
- const { createInterface } = await import("readline");
228
- const { selectOne } = await import("./cli-prompts.mjs");
229
259
  const rl = createInterface({ input: process.stdin, output: process.stdout });
230
260
  try {
231
261
  opts.topic = await selectOne(rl, "Choose a ticket to archive (this will move the file to archive/):", choices);
@@ -240,7 +270,6 @@ export async function runTicketArchive(opts) {
240
270
  }
241
271
  }
242
272
 
243
- const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
244
273
  const found = pickTicketEntry(opts, indexJson);
245
274
  if (!found) throw new Error("ticket archive: no matching entry");
246
275
 
@@ -249,7 +278,7 @@ export async function runTicketArchive(opts) {
249
278
  throw new Error("ticket archive: file not found " + found.path);
250
279
  }
251
280
 
252
- const archiveDir = join(opts.cwd, TICKET_DIR_NAME, "archive", found.group || "sub");
281
+ const archiveDir = join(ticketDir, "archive", found.group || "sub");
253
282
  if (!opts.dryRun) mkdirSync(archiveDir, { recursive: true });
254
283
 
255
284
  const fileName = found.path.split(/[/\\]/).pop();
@@ -261,7 +290,7 @@ export async function runTicketArchive(opts) {
261
290
  if (!existsSync(reportSrc)) {
262
291
  throw new Error("ticket archive: report file not found " + opts.report);
263
292
  }
264
- const reportDir = join(opts.cwd, TICKET_DIR_NAME, "reports");
293
+ const reportDir = join(ticketDir, "reports");
265
294
  if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
266
295
 
267
296
  const reportDest = join(reportDir, `REPORT-${fileName}`);
@@ -291,11 +320,14 @@ export async function runTicketArchive(opts) {
291
320
  }
292
321
 
293
322
  writeTicketIndexJson(opts.cwd, indexJson, opts);
294
- writeTicketListFile(opts.cwd, indexJson.entries, opts);
323
+ if (opts.render) writeTicketListFile(opts.cwd, indexJson.entries, opts);
324
+ syncActiveTicketId(opts.cwd);
295
325
  }
296
326
 
297
327
  export async function runTicketReports(opts) {
298
- const reportDir = join(opts.cwd, TICKET_DIR_NAME, "reports");
328
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
329
+ if (!ticketDir) throw new Error("No ticket system found.");
330
+ const reportDir = join(ticketDir, "reports");
299
331
  console.log("\nšŸ“„ Agent Reports:");
300
332
  if (!existsSync(reportDir)) {
301
333
  console.log(" No reports found.");
@@ -1,13 +1,14 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync, copyFileSync } from "fs";
2
- import { basename, dirname, join, relative } from "path";
2
+ import { basename, dirname, join, relative, resolve } from "path";
3
3
  import { createHash } from "crypto";
4
4
  import { hostname as osHostname } from "os";
5
- import { toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId, detectProjectFromBody, deriveTopicFromBaseName, parseFrontMatter, stringifyFrontMatter, loadInitConfig } from "./cli-utils.mjs";
6
-
7
- export const TICKET_DIR_NAME = ".deuk-agent-ticket";
8
- export const TICKET_INDEX_FILENAME = "INDEX.json";
9
- export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
10
- export const TICKET_LIST_TEMPLATE_FILENAME = "TICKET_LIST.template.md";
5
+ import {
6
+ toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId,
7
+ detectProjectFromBody, deriveTopicFromBaseName, parseFrontMatter, stringifyFrontMatter,
8
+ loadInitConfig, findFileRecursively,
9
+ AGENT_ROOT_DIR, TICKET_SUBDIR, TEMPLATE_SUBDIR, RULES_SUBDIR,
10
+ TICKET_DIR_NAME, TICKET_INDEX_FILENAME, TICKET_LIST_FILENAME, TICKET_LIST_TEMPLATE_FILENAME
11
+ } from "./cli-utils.mjs";
11
12
 
12
13
  const DEFAULT_TICKET_LIST_TEMPLATE = `# Ticket List
13
14
 
@@ -32,37 +33,52 @@ const DEFAULT_TICKET_LIST_TEMPLATE = `# Ticket List
32
33
  `;
33
34
 
34
35
  export function detectConsumerTicketDir(startDir, opts = {}) {
35
- let curr = startDir;
36
+ let curr = resolve(startDir);
36
37
  while (curr && curr !== dirname(curr)) {
37
- const p = join(curr, TICKET_DIR_NAME);
38
- if (existsSync(p)) return p;
38
+ // Priority 1: New consolidated path
39
+ const newPath = join(curr, AGENT_ROOT_DIR, TICKET_SUBDIR);
40
+ if (existsSync(newPath)) return newPath;
41
+
42
+ // Priority 2: Legacy path
43
+ const legacyPath = join(curr, ".deuk-agent-ticket");
44
+ if (existsSync(legacyPath)) return legacyPath;
45
+
39
46
  curr = dirname(curr);
40
47
  }
41
- // If not found and creation allowed (init), return default local path.
42
- // Otherwise return null to indicate no ticket system found.
43
- return opts.createIfMissing ? join(startDir, TICKET_DIR_NAME) : null;
48
+ // If not found and creation allowed (init), return default new local path.
49
+ return opts.createIfMissing ? join(startDir, AGENT_ROOT_DIR, TICKET_SUBDIR) : null;
44
50
  }
45
51
 
46
52
  export function readTicketIndexJson(cwd) {
47
53
  const dir = detectConsumerTicketDir(cwd);
48
54
  if (!dir) return { version: 1, updatedAt: null, entries: [] };
49
55
  const p = join(dir, TICKET_INDEX_FILENAME);
50
- if (!existsSync(p)) return { version: 1, updatedAt: null, entries: [] };
56
+ if (!existsSync(p)) {
57
+ return { version: 1, updatedAt: null, entries: [] };
58
+ }
51
59
  try {
52
60
  const j = JSON.parse(readFileSync(p, "utf8"));
53
61
  const entries = Array.isArray(j.entries) ? j.entries.map(e => ({ ...e, status: e.status || "open" })) : [];
54
- return { version: 1, updatedAt: j.updatedAt ?? null, entries };
55
- } catch {
56
- return { version: 1, updatedAt: null, entries: [] };
62
+ return { version: 1, updatedAt: j.updatedAt ?? null, activeTicketId: j.activeTicketId ?? null, entries };
63
+ } catch (err) {
64
+ console.error(`[ERROR] Failed to parse ${TICKET_INDEX_FILENAME} at ${p}:`, err.message);
65
+ // Return empty but do NOT overwrite immediately unless forced
66
+ return { version: 1, updatedAt: null, activeTicketId: null, entries: [], _corrupt: true };
57
67
  }
58
68
  }
59
69
 
60
70
  export function writeTicketIndexJson(cwd, indexJson, opts = {}) {
71
+ if (indexJson._corrupt && !opts.force) {
72
+ console.error(`[ABORT] Refusing to overwrite potentially corrupt ${TICKET_INDEX_FILENAME}. Use --force to override.`);
73
+ return;
74
+ }
61
75
  const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
62
76
  const p = join(dir, TICKET_INDEX_FILENAME);
63
77
  if (opts.dryRun) return;
64
78
  mkdirSync(dir, { recursive: true });
65
- writeFileSync(p, JSON.stringify(indexJson, null, 2) + "\n", "utf8");
79
+ const out = { ...indexJson };
80
+ delete out._corrupt;
81
+ writeFileSync(p, JSON.stringify(out, null, 2) + "\n", "utf8");
66
82
  }
67
83
 
68
84
  export function renderTicketListMarkdown(cwd, entries) {
@@ -75,9 +91,10 @@ export function renderTicketListMarkdown(cwd, entries) {
75
91
 
76
92
  let latestBlock = "- No active ticket entries yet.";
77
93
  if (latest) {
78
- const relPath = toPosixPath(relative(ticketDir, join(cwd, latest.path)));
94
+ const absPath = join(cwd, latest.path);
95
+ const fileUri = `file://${toPosixPath(absPath)}`;
79
96
  const safeLatestTitle = String(latest.title || "").replace(/\[|\]/g, '').replace(/\n/g, ' ');
80
- latestBlock = `- [${safeLatestTitle}](${relPath})\n- status: \`${latest.status}\` / group: \`${latest.group}\` / project: \`${latest.project}\``;
97
+ latestBlock = `- [${safeLatestTitle}](${fileUri})\n- status: \`${latest.status}\` / group: \`${latest.group}\` / project: \`${latest.project}\``;
81
98
  }
82
99
 
83
100
  const activeRows = sorted.filter(e => e.status !== "archived").map((e, i) => renderLine(e, i, ticketDir, cwd));
@@ -89,7 +106,7 @@ export function renderTicketListMarkdown(cwd, entries) {
89
106
  (archivedRows.join("\n") || "| - | - | - | No archived tickets | - | - | - | - |");
90
107
 
91
108
  return template
92
- .replaceAll("{{SOURCE_INDEX}}", `${TICKET_DIR_NAME}/${TICKET_INDEX_FILENAME}`)
109
+ .replaceAll("{{SOURCE_INDEX}}", `${toRepoRelativePath(cwd, ticketDir)}/${TICKET_INDEX_FILENAME}`)
93
110
  .replaceAll("{{LATEST_BLOCK}}", latestBlock)
94
111
  .replaceAll("{{ENTRIES_ROWS}}", combinedRows)
95
112
  .replaceAll("{{CMD_LIST}}", "npx deuk-agent-rule ticket list")
@@ -97,14 +114,16 @@ export function renderTicketListMarkdown(cwd, entries) {
97
114
  }
98
115
 
99
116
  function renderLine(e, i, ticketDir, cwd) {
100
- const relPath = toPosixPath(relative(ticketDir, join(cwd, e.path)));
117
+ const absPath = join(cwd, e.path);
118
+ const fileUri = `file://${toPosixPath(absPath)}`;
101
119
  const statusIcon = e.status === "active" ? "šŸ”„ " : (e.status === "archived" ? "šŸ“¦ " : "[ ] ");
102
120
  const safeTitle = String(e.title || "").replace(/\|/g, '&#124;').replace(/(\n|\\n)+/g, ' ');
103
121
  const prio = e.priority || "P2";
104
- return `| ${i + 1} | ${statusIcon}${e.status} | ${prio} | ${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt.split('T')[0]} | [open](${relPath}) |`;
122
+ return `| ${i + 1} | ${statusIcon}${e.status} | ${prio} | ${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt.split('T')[0]} | [open](${fileUri}) |`;
105
123
  }
106
124
 
107
125
  export function writeTicketListFile(cwd, entries, opts = {}) {
126
+ if (!opts.render) return; // Make it on-demand
108
127
  const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
109
128
  const p = join(ticketDir, TICKET_LIST_FILENAME);
110
129
  if (opts.dryRun) return;
@@ -116,9 +135,9 @@ export function writeTicketListFile(cwd, entries, opts = {}) {
116
135
  export function appendTicketEntry(cwd, entry, opts = {}) {
117
136
  const indexJson = readTicketIndexJson(cwd);
118
137
  entry.status = entry.status || "open";
119
- const next = { version: 1, updatedAt: new Date().toISOString(), entries: [entry, ...indexJson.entries] };
138
+ const next = { version: 1, updatedAt: new Date().toISOString(), activeTicketId: indexJson.activeTicketId, entries: [entry, ...indexJson.entries] };
120
139
  writeTicketIndexJson(cwd, next, opts);
121
- writeTicketListFile(cwd, next.entries, opts);
140
+ if (opts.render) writeTicketListFile(cwd, next.entries, opts);
122
141
  }
123
142
 
124
143
  export function updateTicketEntryStatus(cwd, opts = {}) {
@@ -147,7 +166,7 @@ export function updateTicketEntryStatus(cwd, opts = {}) {
147
166
  }
148
167
 
149
168
  export function performUpgradeMigration(cwd, opts = {}) {
150
- const root = join(cwd, TICKET_DIR_NAME);
169
+ const root = detectConsumerTicketDir(cwd, { createIfMissing: true });
151
170
  const archiveDir = join(root, "archive");
152
171
 
153
172
  const files = collectTicketMarkdownFiles(root).filter(p => {
@@ -224,7 +243,8 @@ export function performUpgradeMigration(cwd, opts = {}) {
224
243
  }
225
244
 
226
245
  export function performDefragmentation(cwd, opts = {}) {
227
- const rootTicketDir = join(cwd, TICKET_DIR_NAME);
246
+ const rootTicketDir = detectConsumerTicketDir(cwd);
247
+ if (!rootTicketDir) return;
228
248
  const tickets = collectTicketMarkdownFiles(rootTicketDir).filter(p => {
229
249
  const base = basename(p);
230
250
  return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME && base !== "ACTIVE_TICKET.md";
@@ -239,19 +259,19 @@ export function performDefragmentation(cwd, opts = {}) {
239
259
  if (meta.submodule && meta.submodule !== "global") {
240
260
  const subPath = join(cwd, meta.submodule);
241
261
  if (existsSync(subPath) && statSync(subPath).isDirectory()) {
242
- const subTicketDir = join(subPath, TICKET_DIR_NAME);
262
+ const subTicketDir = join(subPath, AGENT_ROOT_DIR, TICKET_SUBDIR);
243
263
  mkdirSync(subTicketDir, { recursive: true });
244
264
 
245
265
  const relToRoot = relative(rootTicketDir, abs);
246
266
  const destAbs = join(subTicketDir, relToRoot);
247
267
 
248
268
  if (opts.dryRun) {
249
- console.log(`[DRY-RUN] Would move to submodule: ${relToRoot} -> ${meta.submodule}/${TICKET_DIR_NAME}/`);
269
+ console.log(`[DRY-RUN] Would move to submodule: ${relToRoot} -> ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/`);
250
270
  } else {
251
271
  mkdirSync(dirname(destAbs), { recursive: true });
252
272
  copyFileSync(abs, destAbs);
253
273
  unlinkSync(abs);
254
- console.log(`[DEFRAG] Moved: ${meta.submodule}/${TICKET_DIR_NAME}/${relToRoot}`);
274
+ console.log(`[DEFRAG] Moved: ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/${relToRoot}`);
255
275
  modifiedSubmodules.add(subPath);
256
276
  }
257
277
  }
@@ -262,14 +282,15 @@ export function performDefragmentation(cwd, opts = {}) {
262
282
  if (!opts.dryRun) {
263
283
  for (const subCwd of modifiedSubmodules) {
264
284
  rebuildTicketIndexFromTopicFilesIfNeeded(subCwd, { ...opts, force: true });
265
- syncActiveTicketPointer(subCwd);
285
+ syncActiveTicketId(subCwd);
266
286
  }
267
287
  }
268
288
  }
269
289
 
270
290
  function moveFileToArchive(cwd, abs, group) {
271
- const archiveBase = join(cwd, TICKET_DIR_NAME, "archive");
272
- const targetSubDir = (group === TICKET_DIR_NAME || !group) ? "sub" : group;
291
+ const ticketDir = detectConsumerTicketDir(cwd);
292
+ const archiveBase = join(ticketDir, "archive");
293
+ const targetSubDir = (basename(ticketDir) === TICKET_SUBDIR || !group) ? "sub" : group;
273
294
  const targetDir = join(archiveBase, targetSubDir);
274
295
  mkdirSync(targetDir, { recursive: true });
275
296
  const finalAbs = join(targetDir, basename(abs));
@@ -302,21 +323,30 @@ export function collectTicketMarkdownFiles(dir, out = []) {
302
323
  }
303
324
 
304
325
  /**
305
- * Finds all .deuk-agent-ticket directories recursively, skipping node_modules/.git
326
+ * Finds all ticket directories recursively, skipping node_modules/.git
306
327
  */
307
328
  export function discoverAllTicketDirs(baseCwd, out = []) {
308
329
  if (!existsSync(baseCwd)) return out;
309
330
  const entries = readdirSync(baseCwd, { withFileTypes: true });
310
331
 
311
- // If current dir has .deuk-agent-ticket, add it
312
- const local = join(baseCwd, TICKET_DIR_NAME);
313
- if (existsSync(local) && statSync(local).isDirectory()) {
314
- out.push(local);
332
+ // New path check
333
+ const localNew = join(baseCwd, AGENT_ROOT_DIR, TICKET_SUBDIR);
334
+ if (existsSync(localNew) && statSync(localNew).isDirectory()) {
335
+ out.push(localNew);
336
+ }
337
+ // Legacy path check (singular and plural)
338
+ const localLegacy1 = join(baseCwd, ".deuk-agent-ticket");
339
+ if (existsSync(localLegacy1) && statSync(localLegacy1).isDirectory()) {
340
+ out.push(localLegacy1);
341
+ }
342
+ const localLegacy2 = join(baseCwd, ".deuk-agent-tickets");
343
+ if (existsSync(localLegacy2) && statSync(localLegacy2).isDirectory()) {
344
+ out.push(localLegacy2);
315
345
  }
316
346
 
317
347
  for (const ent of entries) {
318
348
  if (!ent.isDirectory()) continue;
319
- if (ent.name === "node_modules" || ent.name === ".git" || ent.name === TICKET_DIR_NAME) continue;
349
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === AGENT_ROOT_DIR || ent.name === ".deuk-agent-ticket" || ent.name === ".deuk-agent-tickets") continue;
320
350
  discoverAllTicketDirs(join(baseCwd, ent.name), out);
321
351
  }
322
352
  return out;
@@ -324,15 +354,15 @@ export function discoverAllTicketDirs(baseCwd, out = []) {
324
354
 
325
355
  export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
326
356
  const indexJson = readTicketIndexJson(cwd);
327
- // Hierarchical Scan: If we are at root, discover all sub-dirs.
328
- const isRoot = existsSync(join(cwd, "DeukAgentRules")) || existsSync(join(cwd, "project_i"));
357
+ // Hierarchical Scan: If we are at root (has AGENT_ROOT_DIR), discover all sub-dirs.
358
+ const isRoot = existsSync(join(cwd, AGENT_ROOT_DIR)) || existsSync(join(cwd, ".git"));
329
359
 
330
360
  let ticketDirs = [];
331
361
  if (opts.recursive !== false && isRoot) {
332
362
  ticketDirs = discoverAllTicketDirs(cwd);
333
363
  } else {
334
- const local = join(cwd, TICKET_DIR_NAME);
335
- if (existsSync(local) && statSync(local).isDirectory()) {
364
+ const local = detectConsumerTicketDir(cwd);
365
+ if (local) {
336
366
  ticketDirs = [local];
337
367
  }
338
368
  }
@@ -366,7 +396,7 @@ export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
366
396
  topic: deriveTopicFromBaseName(basename(abs)),
367
397
  group: basename(dirname(abs)),
368
398
  project,
369
- submodule: meta.submodule || (rel.startsWith(TICKET_DIR_NAME) ? "" : rel.split("/")[0]),
399
+ submodule: meta.submodule || (rel.startsWith(AGENT_ROOT_DIR) ? "" : rel.split("/")[0]),
370
400
  createdAt: meta.createdAt || statSync(abs).mtime.toISOString(),
371
401
  updatedAt: meta.updatedAt || statSync(abs).mtime.toISOString(),
372
402
  path: rel,
@@ -391,84 +421,31 @@ export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
391
421
  return indexJson;
392
422
  }
393
423
 
394
- export function parseLegacyTicketMeta(legacyBody) {
395
- // Supports ## Task: ... or # Plan: ... or # Implementation Plan: ...
396
- const titleMatch = legacyBody.match(/^(?:##\s+Task:|#\s+Plan:|#\s+Implementation Plan:)\s*(.+)$/m);
397
- const title = titleMatch ? titleMatch[1].trim() : "Migrated legacy plan";
398
424
 
399
- let group = "sub";
400
- const lower = legacyBody.toLowerCase();
401
- if (lower.includes("discussion")) group = "discussion";
402
- else if (lower.includes("main")) group = "main";
403
-
404
- return { title, group, project: detectProjectFromBody(legacyBody) };
405
- }
406
-
407
- export function getLegacyMigrationCandidate(cwd) {
408
- const candidateFiles = ["LATEST.md"];
409
- const candidateDirs = [cwd, join(cwd, TICKET_DIR_NAME), join(cwd, "ticket")];
410
- for (const dir of candidateDirs) {
411
- for (const file of candidateFiles) {
412
- const p = join(dir, file);
413
- if (existsSync(p)) {
414
- const body = readFileSync(p, "utf8");
415
- // Check for common plan or task markers
416
- if (body.length > 50 && (/^##\s+Task:/m.test(body) || /^#\s+Plan:/m.test(body))) {
417
- return { latestPath: p, body };
418
- }
419
- }
420
- }
421
- }
422
- return null;
423
- }
424
-
425
- export function syncActiveTicketPointer(cwd) {
425
+ export function syncActiveTicketId(cwd) {
426
426
  const index = readTicketIndexJson(cwd);
427
427
  // Find the single "active" ticket, or the most recent "open" ticket.
428
428
  const activeEntry = index.entries.find(e => e.status === "active") ||
429
- index.entries.find(e => e.status === "open");
429
+ index.entries.find(e => e.status === "open");
430
430
 
431
431
  const ticketDir = detectConsumerTicketDir(cwd);
432
432
  if (!ticketDir) return;
433
433
 
434
- // LATEST.md is deprecated. Remove it on every sync to prevent stale reads.
435
- const legacyLatestPath = join(ticketDir, "LATEST.md");
436
- if (existsSync(legacyLatestPath)) {
437
- unlinkSync(legacyLatestPath);
434
+ const activeId = activeEntry ? activeEntry.id : null;
435
+ if (index.activeTicketId !== activeId) {
436
+ writeTicketIndexJson(cwd, { ...index, activeTicketId: activeId });
438
437
  }
439
438
 
439
+ // Cleanup redundant pointers from legacy approach
440
+ const legacyLatestPath = join(ticketDir, "LATEST.md");
440
441
  const pointerPathMd = join(ticketDir, "ACTIVE_TICKET.md");
441
442
  const pointerPathJson = join(ticketDir, "ACTIVE_TICKET.json");
442
443
 
443
- if (activeEntry) {
444
- const srcAbs = join(cwd, activeEntry.path);
445
- if (existsSync(srcAbs)) {
446
- 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}\``;
447
- const redirectContent = stringifyFrontMatter({
448
- id: activeEntry.id || "pointer",
449
- title: activeEntry.title || "ACTIVE_TICKET_POINTER",
450
- topic: activeEntry.topic || "",
451
- status: activeEntry.status || "active",
452
- submodule: activeEntry.submodule || "",
453
- project: activeEntry.project || "",
454
- createdAt: activeEntry.createdAt || new Date().toISOString()
455
- }, redirectBody);
456
- writeFileSync(pointerPathMd, redirectContent, "utf8");
457
- writeFileSync(pointerPathJson, JSON.stringify({ ...activeEntry, syncedAt: new Date().toISOString() }, null, 2), "utf8");
458
-
459
- // Hook: Optional background sync to remote pipeline
460
- const config = loadInitConfig(cwd);
461
- if (config && config.remoteSync && config.pipelineUrl) {
462
- syncToPipeline(config.pipelineUrl, { action: "sync_active", ticket: activeEntry });
463
- }
464
- return;
444
+ for (const p of [legacyLatestPath, pointerPathMd, pointerPathJson]) {
445
+ if (existsSync(p)) {
446
+ unlinkSync(p);
465
447
  }
466
448
  }
467
-
468
- // If no active ticket, clear pointers
469
- const noTicketMsg = "# No Active Ticket Found\nUse `npx deuk-agent-rule ticket list` to find open tasks.\n";
470
- writeFileSync(pointerPathMd, noTicketMsg, "utf8");
471
- writeFileSync(pointerPathJson, JSON.stringify({ status: "none", message: "No active ticket" }), "utf8");
472
449
  }
473
450
 
474
451
 
@@ -484,28 +461,58 @@ export function getHostnameSlug() {
484
461
  }
485
462
  }
486
463
 
464
+ /**
465
+ * Normalizes all paths in INDEX.json by finding the actual files in the ticket directory.
466
+ * Useful after migration or manual folder restructuring.
467
+ */
468
+ export function normalizeTicketPaths(cwd, opts = {}) {
469
+ const index = readTicketIndexJson(cwd);
470
+ const ticketDir = detectConsumerTicketDir(cwd);
471
+ const entries = index.entries || [];
472
+ let modified = false;
473
+
474
+ for (const entry of entries) {
475
+ if (!entry.path) continue;
476
+
477
+ const currentAbs = join(cwd, entry.path);
478
+ if (!existsSync(currentAbs)) {
479
+ const fileName = basename(entry.path);
480
+ const found = findFileRecursively(ticketDir, fileName);
481
+ if (found) {
482
+ const newRel = toRepoRelativePath(cwd, found);
483
+ if (entry.path !== newRel) {
484
+ entry.path = newRel;
485
+ modified = true;
486
+ }
487
+ }
488
+ }
489
+ }
490
+
491
+ if (modified) {
492
+ index.updatedAt = new Date().toISOString();
493
+ writeTicketIndexJson(cwd, index);
494
+ if (!opts.silent) console.log(`[NORMALIZE] Corrected stale paths in ${basename(cwd)}/INDEX.json`);
495
+ }
496
+ return modified;
497
+ }
498
+
487
499
  /**
488
500
  * Computes next sequential 3-digit ticket number by scanning all entries
489
- * in the current INDEX.json. Parses both legacy (`ticket_NNN_*`) and
490
- * new (`NNN-topic-hostname`) formats for backward compatibility.
501
+ * in the current INDEX.json. Parses new (`NNN-topic-hostname`) format.
491
502
  *
492
503
  * @param {object[]} existingEntries - entries array from INDEX.json
493
504
  * @returns {{ num: number, hostname: string }}
494
505
  */
495
506
  export function computeNextTicketNumber(existingEntries) {
496
507
  const hostname = getHostnameSlug();
497
- // Legacy: ticket_NNN_* | New: NNN-* (NNN is strictly 3-4 digits, NOT a unix timestamp)
498
- const legacyRe = /^ticket_(\d{3,4})_/;
499
508
  const newRe = /^(\d{3,4})-/;
500
509
  let max = 0;
501
510
  for (const e of (existingEntries || [])) {
502
511
  const id = String(e.id || '');
503
- const mLegacy = id.match(legacyRe);
504
- const mNew = id.match(newRe);
505
- const m = mLegacy || mNew;
512
+ const m = id.match(newRe);
506
513
  if (m) {
507
514
  const n = parseInt(m[1], 10);
508
- if (n > max) max = n;
515
+ if (n > max && n < 10000) max = n; // Sanity check for 4-digit limit
509
516
  }
510
517
  }
511
518
  return { num: max + 1, hostname };
@@ -521,23 +528,21 @@ export function computeNextTicketNumber(existingEntries) {
521
528
  * @param {object[]} existingEntries - entries array from INDEX.json (may be empty)
522
529
  */
523
530
  export function generateTicketId(topicSlug, existingEntries) {
524
- const { num, hostname } = computeNextTicketNumber(existingEntries);
525
- const numStr = String(num).padStart(3, '0');
526
- const slug = toSlug(topicSlug || 'ticket').slice(0, 32);
527
- return `${numStr}-${slug}-${hostname}`;
528
- }
529
-
530
- /**
531
- * @deprecated Use generateTicketId(topicSlug, existingEntries)
532
- * Kept for backwards compatibility.
533
- */
534
- export function generateTicketIdLegacy(title) {
535
- const seed = `${title}-${Date.now()}-${Math.random()}`;
536
- try {
537
- return "ticket_" + createHash("md5").update(seed).digest("hex").slice(0, 12);
538
- } catch {
539
- return "ticket_" + Date.now() + "_" + Math.floor(Math.random() * 1000);
531
+ const hostname = getHostnameSlug();
532
+ const slug = toSlug(topicSlug || 'ticket');
533
+
534
+ // If topicSlug already starts with NNN-, respect it
535
+ const match = slug.match(/^(\d{3,4})-(.*)/);
536
+ if (match) {
537
+ const numStr = match[1];
538
+ const restSlug = match[2].slice(0, 32);
539
+ return `${numStr}-${restSlug}-${hostname}`;
540
540
  }
541
+
542
+ const { num } = computeNextTicketNumber(existingEntries);
543
+ const numStr = String(num).padStart(3, '0');
544
+ const finalSlug = slug.slice(0, 32);
545
+ return `${numStr}-${finalSlug}-${hostname}`;
541
546
  }
542
547
 
543
548
  /**