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.
- package/CHANGELOG.md +33 -0
- package/README.ko.md +40 -16
- package/README.md +30 -7
- package/bundle/AGENTS.md +70 -36
- package/bundle/rules/multi-ai-workflow.mdc +0 -1
- package/bundle/templates/TICKET_TEMPLATE.md +1 -1
- package/package.json +4 -1
- package/scripts/cli-args.mjs +9 -0
- package/scripts/cli-init-commands.mjs +27 -2
- package/scripts/cli-init-logic.mjs +4 -0
- package/scripts/cli-prompts.mjs +17 -45
- package/scripts/cli-ticket-commands.mjs +100 -56
- package/scripts/cli-ticket-logic.mjs +322 -46
- package/scripts/cli-utils.mjs +64 -1
- package/scripts/cli.mjs +18 -6
- package/scripts/sync-oss.mjs +1 -1
|
@@ -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
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 (
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
79
|
+
latestBlock = `- [${safeLatestTitle}](${relPath})\n- status: \`${latest.status}\` / group: \`${latest.group}\` / project: \`${latest.project}\``;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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}}",
|
|
93
|
+
.replaceAll("{{ENTRIES_ROWS}}", combinedRows)
|
|
92
94
|
.replaceAll("{{CMD_LIST}}", "npx deuk-agent-rule ticket list")
|
|
93
95
|
.replaceAll("{{CMD_USE_LATEST}}", "npx deuk-agent-rule ticket use --latest");
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
function renderLine(e, i, ticketDir, cwd) {
|
|
99
|
+
const relPath = toPosixPath(relative(ticketDir, join(cwd, e.path)));
|
|
100
|
+
const statusIcon = e.status === "active" ? "š„ " : (e.status === "archived" ? "š¦ " : "[ ] ");
|
|
101
|
+
const safeTitle = String(e.title || "").replace(/\|/g, '|').replace(/(\n|\\n)+/g, ' ');
|
|
102
|
+
return `| ${i + 1} | ${statusIcon}${e.status} | ${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt.split('T')[0]} | [open](${relPath}) |`;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
export function writeTicketListFile(cwd, entries, opts = {}) {
|
|
97
106
|
const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
|
|
98
107
|
const p = join(ticketDir, TICKET_LIST_FILENAME);
|
|
@@ -135,60 +144,243 @@ export function updateTicketEntryStatus(cwd, opts = {}) {
|
|
|
135
144
|
return entry;
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
export function performUpgradeMigration(cwd, opts = {}) {
|
|
148
|
+
const root = join(cwd, TICKET_DIR_NAME);
|
|
149
|
+
const archiveDir = join(root, "archive");
|
|
150
|
+
|
|
151
|
+
const files = collectTicketMarkdownFiles(root).filter(p => {
|
|
152
|
+
const base = basename(p);
|
|
153
|
+
return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME && base !== "ACTIVE_TICKET.md";
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
console.log(`[UPGRADE] Scanning ${files.length} tickets for V2 migration...`);
|
|
157
|
+
|
|
158
|
+
let count = 0;
|
|
159
|
+
for (const abs of files) {
|
|
160
|
+
const rel = toRepoRelativePath(cwd, abs);
|
|
161
|
+
const body = readFileSync(abs, "utf8");
|
|
162
|
+
const { meta, content } = parseFrontMatter(body);
|
|
163
|
+
|
|
164
|
+
if (meta.id && meta.status) {
|
|
165
|
+
// Already V2, but check if it needs archiving
|
|
166
|
+
const isAlreadyInArchive = rel.includes("/archive/");
|
|
167
|
+
if (meta.status === "archived" && !isAlreadyInArchive && !opts.dryRun) {
|
|
168
|
+
// Move to archive if status is archived but file is in root
|
|
169
|
+
moveFileToArchive(cwd, abs, meta.group || "sub");
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// V1 -> V2 Migration
|
|
175
|
+
const titleMatch = content.match(/^##\s+Task:\s*(.+)$/m);
|
|
176
|
+
const title = meta.title || titleMatch?.[1]?.trim() || basename(abs).replace(/\.md$/i, "");
|
|
177
|
+
|
|
178
|
+
// Check if finished (all phases [x])
|
|
179
|
+
const phases = content.match(/\[[ x/]]/g);
|
|
180
|
+
const finished = phases && phases.length > 0 && phases.every(p => p.includes("x"));
|
|
181
|
+
const isAlreadyInArchive = rel.includes("/archive/");
|
|
182
|
+
|
|
183
|
+
let status = meta.status || "open";
|
|
184
|
+
if (finished || isAlreadyInArchive) {
|
|
185
|
+
status = "archived";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const project = meta.project || detectProjectFromBody(content);
|
|
189
|
+
|
|
190
|
+
const newMeta = {
|
|
191
|
+
id: meta.id || `ticket_${statSync(abs).mtimeMs}`,
|
|
192
|
+
title,
|
|
193
|
+
status,
|
|
194
|
+
submodule: meta.submodule || (content.includes("DeukPack") ? "DeukPack" : ""),
|
|
195
|
+
project,
|
|
196
|
+
createdAt: meta.createdAt || statSync(abs).birthtime.toISOString(),
|
|
197
|
+
updatedAt: new Date().toISOString()
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const migratedBody = stringifyFrontMatter(newMeta, content);
|
|
201
|
+
|
|
202
|
+
if (opts.dryRun) {
|
|
203
|
+
console.log(`[DRY-RUN] Would upgrade: ${rel} (status: ${status})`);
|
|
204
|
+
} else {
|
|
205
|
+
let finalAbs = abs;
|
|
206
|
+
if (status === "archived" && !isAlreadyInArchive) {
|
|
207
|
+
finalAbs = moveFileToArchive(cwd, abs, basename(dirname(abs)));
|
|
208
|
+
}
|
|
209
|
+
writeFileSync(finalAbs, migratedBody, "utf8");
|
|
210
|
+
console.log(`[OK] Upgraded: ${toRepoRelativePath(cwd, finalAbs)}`);
|
|
211
|
+
count++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!opts.dryRun) {
|
|
216
|
+
rebuildTicketIndexFromTopicFilesIfNeeded(cwd, { ...opts, force: true });
|
|
217
|
+
performDefragmentation(cwd, opts); // NEW: Split to submodules
|
|
218
|
+
syncActiveTicketPointer(cwd);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return count;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function performDefragmentation(cwd, opts = {}) {
|
|
225
|
+
const rootTicketDir = join(cwd, TICKET_DIR_NAME);
|
|
226
|
+
const tickets = collectTicketMarkdownFiles(rootTicketDir).filter(p => {
|
|
227
|
+
const base = basename(p);
|
|
228
|
+
return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== TICKET_LIST_TEMPLATE_FILENAME && base !== "ACTIVE_TICKET.md";
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
console.log(`[DEFRAG] Checking ${tickets.length} tickets for submodule placement...`);
|
|
232
|
+
|
|
233
|
+
const modifiedSubmodules = new Set();
|
|
234
|
+
|
|
235
|
+
for (const abs of tickets) {
|
|
236
|
+
const { meta } = parseFrontMatter(readFileSync(abs, "utf8"));
|
|
237
|
+
if (meta.submodule && meta.submodule !== "global") {
|
|
238
|
+
const subPath = join(cwd, meta.submodule);
|
|
239
|
+
if (existsSync(subPath) && statSync(subPath).isDirectory()) {
|
|
240
|
+
const subTicketDir = join(subPath, TICKET_DIR_NAME);
|
|
241
|
+
mkdirSync(subTicketDir, { recursive: true });
|
|
242
|
+
|
|
243
|
+
const relToRoot = relative(rootTicketDir, abs);
|
|
244
|
+
const destAbs = join(subTicketDir, relToRoot);
|
|
245
|
+
|
|
246
|
+
if (opts.dryRun) {
|
|
247
|
+
console.log(`[DRY-RUN] Would move to submodule: ${relToRoot} -> ${meta.submodule}/${TICKET_DIR_NAME}/`);
|
|
248
|
+
} else {
|
|
249
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
250
|
+
copyFileSync(abs, destAbs);
|
|
251
|
+
unlinkSync(abs);
|
|
252
|
+
console.log(`[DEFRAG] Moved: ${meta.submodule}/${TICKET_DIR_NAME}/${relToRoot}`);
|
|
253
|
+
modifiedSubmodules.add(subPath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Re-index all touched submodules
|
|
260
|
+
if (!opts.dryRun) {
|
|
261
|
+
for (const subCwd of modifiedSubmodules) {
|
|
262
|
+
rebuildTicketIndexFromTopicFilesIfNeeded(subCwd, { ...opts, force: true });
|
|
263
|
+
syncActiveTicketPointer(subCwd);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function moveFileToArchive(cwd, abs, group) {
|
|
269
|
+
const archiveBase = join(cwd, TICKET_DIR_NAME, "archive");
|
|
270
|
+
const targetSubDir = (group === TICKET_DIR_NAME || !group) ? "sub" : group;
|
|
271
|
+
const targetDir = join(archiveBase, targetSubDir);
|
|
272
|
+
mkdirSync(targetDir, { recursive: true });
|
|
273
|
+
const finalAbs = join(targetDir, basename(abs));
|
|
274
|
+
if (finalAbs !== abs) {
|
|
275
|
+
if (existsSync(finalAbs)) {
|
|
276
|
+
unlinkSync(abs); // Already exists in archive
|
|
277
|
+
} else {
|
|
278
|
+
writeFileSync(finalAbs, readFileSync(abs, "utf8"), "utf8");
|
|
279
|
+
unlinkSync(abs);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return finalAbs;
|
|
283
|
+
}
|
|
284
|
+
|
|
138
285
|
export function collectTicketMarkdownFiles(dir, out = []) {
|
|
139
286
|
if (!existsSync(dir)) return out;
|
|
140
287
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
141
288
|
const abs = join(dir, ent.name);
|
|
289
|
+
// Ignore common noise
|
|
290
|
+
if (ent.name === "node_modules" || ent.name === ".git") continue;
|
|
291
|
+
|
|
142
292
|
if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
|
|
143
|
-
else if (ent.isFile() && /\.md$/i.test(ent.name))
|
|
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
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
const next = { version: 1, updatedAt: new Date().toISOString(), 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"
|
|
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)
|
|
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
|
+
}
|