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.
- package/CHANGELOG.md +61 -0
- package/README.ko.md +50 -11
- package/README.md +50 -11
- package/bundle/.cursorrules +8 -4
- package/bundle/AGENTS.md +94 -21
- package/bundle/gemini.md +26 -0
- package/bundle/rules/multi-ai-workflow.mdc +2 -2
- package/bundle/rules.d/core-workflow.md +48 -0
- package/bundle/rules.d/deukrag-mcp.md +37 -0
- package/bundle/templates/TICKET_TEMPLATE.md +36 -15
- package/package.json +2 -4
- package/scripts/cli-args.mjs +1 -0
- package/scripts/cli-init-commands.mjs +246 -44
- package/scripts/cli-init-logic.mjs +27 -6
- package/scripts/cli-rule-compiler.mjs +102 -0
- package/scripts/cli-ticket-commands.mjs +49 -17
- package/scripts/cli-ticket-logic.mjs +136 -131
- package/scripts/cli-utils.mjs +70 -6
- package/scripts/cli.mjs +9 -1
- package/scripts/merge-logic.mjs +0 -28
- package/scripts/sync-bundle.mjs +15 -0
|
@@ -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 {
|
|
5
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}](${
|
|
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}}", `${
|
|
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
|
|
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, '|').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](${
|
|
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 =
|
|
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 =
|
|
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,
|
|
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}/${
|
|
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}/${
|
|
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
|
-
|
|
285
|
+
syncActiveTicketId(subCwd);
|
|
266
286
|
}
|
|
267
287
|
}
|
|
268
288
|
}
|
|
269
289
|
|
|
270
290
|
function moveFileToArchive(cwd, abs, group) {
|
|
271
|
-
const
|
|
272
|
-
const
|
|
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
|
|
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
|
-
//
|
|
312
|
-
const
|
|
313
|
-
if (existsSync(
|
|
314
|
-
out.push(
|
|
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 ===
|
|
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,
|
|
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 =
|
|
335
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
index.entries.find(e => e.status === "open");
|
|
430
430
|
|
|
431
431
|
const ticketDir = detectConsumerTicketDir(cwd);
|
|
432
432
|
if (!ticketDir) return;
|
|
433
433
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
|
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
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
/**
|