deuk-agent-rule 2.2.2 → 2.3.1

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,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, readdirSync, rmSync, statSync } from "fs";
2
2
  import { basename, join, dirname, relative, resolve } from "path";
3
- import { toSlug, toRepoRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath } from "./cli-utils.mjs";
4
- import { TICKET_DIR_NAME, appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, detectConsumerTicketDir, readTicketIndexJson, writeTicketIndexJson, writeTicketListFile } from "./cli-ticket-logic.mjs";
3
+ import { toSlug, toRepoRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter } from "./cli-utils.mjs";
4
+ import { TICKET_DIR_NAME, appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, detectConsumerTicketDir, readTicketIndexJson, writeTicketIndexJson, writeTicketListFile, syncActiveTicketPointer, generateTicketId, syncToPipeline } from "./cli-ticket-logic.mjs";
5
+ import { loadInitConfig } from "./cli-utils.mjs";
6
+ import ejs from "ejs";
5
7
 
6
8
  import { createInterface } from "readline";
7
9
  import { selectOne } from "./cli-prompts.mjs";
@@ -19,21 +21,60 @@ export async function runTicketCreate(opts) {
19
21
  path = resolveReferencedTicketPath(opts);
20
22
  source = "ticket-reference";
21
23
  } else {
22
- let body = opts.content ? String(opts.content).replace(/\\n/g, '\n') : "";
23
- if (!body && opts.from) body = readFileSync(join(opts.cwd, opts.from), "utf8");
24
- const abs = join(opts.cwd, TICKET_DIR_NAME, group, `${topic}-${Date.now()}.md`);
25
- mkdirSync(join(opts.cwd, TICKET_DIR_NAME, group), { recursive: true });
24
+ let tplText = "";
25
+ const consumerTplPath = join(opts.cwd, ".deuk-agent-templates", "TICKET_TEMPLATE.md");
26
+ const bundleTplPath = join(new URL('.', import.meta.url).pathname, "..", "bundle", "templates", "TICKET_TEMPLATE.md");
27
+
28
+ if (existsSync(consumerTplPath)) tplText = readFileSync(consumerTplPath, "utf8");
29
+ else if (existsSync(bundleTplPath)) tplText = readFileSync(bundleTplPath, "utf8");
30
+ else throw new Error("ticket create: Template not found. Refusing to create an empty ticket.");
31
+
32
+ // Find nearest or create in CWD if missing
33
+ const ticketDir = detectConsumerTicketDir(opts.cwd, { createIfMissing: true });
34
+ const abs = join(ticketDir, group, `${topic}-${Date.now()}.md`);
35
+ mkdirSync(join(ticketDir, group), { recursive: true });
26
36
  path = toRepoRelativePath(opts.cwd, abs);
27
- const marker = `\n\n<!-- Ticket (repo-relative): ${path} -->\n`;
28
- writeFileSync(abs, body.trimEnd() + marker, "utf8");
37
+
38
+ const ticketId = generateTicketId(title);
39
+ const meta = {
40
+ id: ticketId,
41
+ title,
42
+ topic,
43
+ status: "open",
44
+ submodule: opts.submodule || "",
45
+ project: opts.project || "global",
46
+ createdAt: new Date().toISOString(),
47
+ };
48
+
49
+ const ejsFrontMatter = `---
50
+ id: <%= meta.id %>
51
+ title: "<%- meta.title.replace(/"/g, '\\"') %>"
52
+ topic: <%= meta.topic %>
53
+ status: open
54
+ submodule: <%= meta.submodule %>
55
+ project: <%= meta.project %>
56
+ createdAt: <%= meta.createdAt %>
57
+ ---
58
+
59
+ `;
60
+ const finalContent = ejs.render(ejsFrontMatter + tplText, { meta });
61
+ writeFileSync(abs, finalContent, "utf8");
29
62
  source = "ticket-create";
63
+
64
+ // Remote Sync Hook
65
+ const config = loadInitConfig(opts.cwd);
66
+ if (config && config.remoteSync && config.pipelineUrl) {
67
+ syncToPipeline(config.pipelineUrl, { action: "create", ticket: meta });
68
+ }
69
+
70
+ appendTicketEntry(opts.cwd, {
71
+ id: ticketId,
72
+ title, topic, group, project: opts.project || "global",
73
+ createdAt: new Date().toISOString(), path, source
74
+ }, opts);
30
75
  }
31
76
 
32
- appendTicketEntry(opts.cwd, {
33
- id: `ticket_${Date.now()}`,
34
- title, topic, group, project: opts.project || "global",
35
- createdAt: new Date().toISOString(), path, source
36
- }, opts);
77
+ syncActiveTicketPointer(opts.cwd);
37
78
  }
38
79
 
39
80
  export async function runTicketList(opts) {
@@ -42,24 +83,64 @@ export async function runTicketList(opts) {
42
83
  throw new Error("No ticket system found. Please run 'npx deuk-agent-rule init' first.");
43
84
  }
44
85
  const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
86
+ syncActiveTicketPointer(opts.cwd);
45
87
  let rows = index.entries;
88
+
46
89
 
47
- if (!opts.all) {
48
- const targetStatus = opts.status || "open";
49
- rows = rows.filter(e => e.status === targetStatus);
90
+ if (opts.active) {
91
+ rows = rows.filter(e => e.status === "active" || e.status === "open");
92
+ } else if (opts.archived) {
93
+ rows = rows.filter(e => e.status === "archived");
94
+ } else if (!opts.all) {
95
+ // Default: major/active list (open or active)
96
+ rows = rows.filter(e => e.status === "open" || e.status === "active");
50
97
  }
51
98
 
52
99
  if (opts.group) rows = rows.filter(e => e.group === opts.group);
53
100
  if (opts.project) rows = rows.filter(e => e.project === opts.project);
101
+ if (opts.submodule) rows = rows.filter(e => e.submodule === opts.submodule);
54
102
 
55
- console.log("# STATUS GROUP PROJECT CREATED TITLE");
103
+ if (opts.json) {
104
+ console.log(JSON.stringify(rows, null, 2));
105
+ return;
106
+ }
107
+
108
+ console.log("# STATUS SUBMODULE GROUP PROJECT CREATED TITLE");
56
109
  rows.slice(0, opts.limit).forEach((e, idx) => {
57
110
  const stat = (e.status === "closed" ? "[x]" : "[ ]").padEnd(7);
111
+ const sub = (e.submodule || "-").padEnd(11);
58
112
  const safeTitle = String(e.title || e.topic || "").replace(/(\n|\\n)+/g, " ").slice(0, 50);
59
- console.log(`${String(idx+1).padEnd(2)} ${stat} ${e.group.padEnd(10)} ${e.project.padEnd(11)} ${e.createdAt.padEnd(24)} ${safeTitle}`);
113
+ console.log(`${String(idx+1).padEnd(2)} ${stat} ${sub} ${String(e.group||"").padEnd(10)} ${String(e.project||"").padEnd(11)} ${String(e.createdAt||"").padEnd(24)} ${safeTitle}`);
60
114
  });
61
115
  }
62
116
 
117
+ export async function runTicketMeta(opts) {
118
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
119
+ const found = pickTicketEntry(opts, index);
120
+ if (!found) throw new Error("ticket meta: no matching ticket found");
121
+
122
+ if (opts.json) {
123
+ console.log(JSON.stringify(found, null, 2));
124
+ } else {
125
+ console.log(`Ticket Meta [${found.topic}]`);
126
+ Object.entries(found).forEach(([k, v]) => console.log(` ${k}: ${v}`));
127
+ }
128
+ }
129
+
130
+ export async function runTicketConnect(opts) {
131
+ const config = loadInitConfig(opts.cwd);
132
+ const url = opts.remote || config?.pipelineUrl;
133
+ if (!url) throw new Error("ticket connect: no pipeline URL configured or provided via --remote");
134
+
135
+ console.log(`Connecting to AI Pipeline at ${url} ...`);
136
+ const success = await syncToPipeline(url, { action: "ping", timestamp: new Date().toISOString() });
137
+ if (success) {
138
+ console.log("SUCCESS: Pipeline is reachable.");
139
+ } else {
140
+ console.error("FAILED: Could not connect to pipeline or returned non-OK status.");
141
+ }
142
+ }
143
+
63
144
  import { updateTicketEntryStatus } from "./cli-ticket-logic.mjs";
64
145
 
65
146
  export async function runTicketClose(opts) {
@@ -85,6 +166,7 @@ export async function runTicketClose(opts) {
85
166
  }
86
167
  opts.status = "closed";
87
168
  const entry = updateTicketEntryStatus(opts.cwd, opts);
169
+ syncActiveTicketPointer(opts.cwd);
88
170
  console.log(`ticket: closed -> ${entry.topic} (${entry.path})`);
89
171
  }
90
172
 
@@ -117,45 +199,7 @@ export async function runTicketUse(opts) {
117
199
  }
118
200
  }
119
201
 
120
- import { getLegacyMigrationCandidate, parseLegacyTicketMeta } from "./cli-ticket-logic.mjs";
121
202
 
122
- export async function runTicketMigrate(opts) {
123
- const candidate = getLegacyMigrationCandidate(opts.cwd);
124
- if (!candidate) {
125
- console.log("ticket: no legacy LATEST.md migration candidate found");
126
- return;
127
- }
128
-
129
- const { title, group, project } = parseLegacyTicketMeta(candidate.body);
130
- const topic = toSlug(title);
131
- const stamp = Date.now();
132
- const relPath = join(TICKET_DIR_NAME, group, `${topic}-${stamp}.md`);
133
- const absPath = join(opts.cwd, relPath);
134
-
135
- if (opts.dryRun) {
136
- console.log("ticket: would migrate -> " + relPath);
137
- } else {
138
- mkdirSync(dirname(absPath), { recursive: true });
139
- const marker = `\n\n<!-- Ticket (repo-relative): ${relPath} -->\n`;
140
- writeFileSync(absPath, candidate.body.trimEnd() + marker, "utf8");
141
- console.log("ticket: migrated body -> " + relPath);
142
-
143
- appendTicketEntry(opts.cwd, {
144
- id: `ticket_migrated_${stamp}`,
145
- title,
146
- topic,
147
- group,
148
- project,
149
- createdAt: new Date().toISOString(),
150
- path: relPath,
151
- source: "ticket-migrate",
152
- }, opts);
153
- if (existsSync(candidate.latestPath)) {
154
- unlinkSync(candidate.latestPath);
155
- console.log("ticket: deleted legacy LATEST.md");
156
- }
157
- }
158
- }
159
203
 
160
204
  export function pickTicketEntry(opts, indexJson) {
161
205
  const rows = [...indexJson.entries].sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
@@ -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, '&#124;').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, '&#124;').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
+ }