deuk-agent-rule 2.2.0 → 2.2.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,229 +1,229 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "fs";
2
- import { basename, dirname, join, relative } from "path";
3
- import { toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId, detectProjectFromBody, deriveTopicFromBaseName } from "./cli-utils.mjs";
4
-
5
- export const TICKET_DIR_NAME = ".deuk-agent-ticket";
6
- export const TICKET_INDEX_FILENAME = "INDEX.json";
7
- export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
8
- export const TICKET_LIST_TEMPLATE_FILENAME = "TICKET_LIST.template.md";
9
-
10
- const DEFAULT_TICKET_LIST_TEMPLATE = `# Ticket List
11
-
12
- > Source index: \`{{SOURCE_INDEX}}\`
13
-
14
- ## Latest
15
-
16
- {{LATEST_BLOCK}}
17
-
18
- ## Entries
19
-
20
- | # | Title | Group | Project | Created | Path |
21
- |---|---|---|---|---|---|
22
- {{ENTRIES_ROWS}}
23
-
24
- ## Commands
25
-
26
- \`\`\`bash
27
- {{CMD_LIST}}
28
- {{CMD_USE_LATEST}}
29
- \`\`\`
30
- `;
31
-
32
- export function detectConsumerTicketDir(startDir, opts = {}) {
33
- let curr = startDir;
34
- while (curr && curr !== dirname(curr)) {
35
- const p = join(curr, TICKET_DIR_NAME);
36
- if (existsSync(p)) return p;
37
- curr = dirname(curr);
38
- }
39
- // If not found and creation allowed (init), return default local path.
40
- // Otherwise return null to indicate no ticket system found.
41
- return opts.createIfMissing ? join(startDir, TICKET_DIR_NAME) : null;
42
- }
43
-
44
- export function readTicketIndexJson(cwd) {
45
- const dir = detectConsumerTicketDir(cwd);
46
- if (!dir) return { version: 1, updatedAt: null, entries: [] };
47
- const p = join(dir, TICKET_INDEX_FILENAME);
48
- if (!existsSync(p)) return { version: 1, updatedAt: null, entries: [] };
49
- try {
50
- const j = JSON.parse(readFileSync(p, "utf8"));
51
- const entries = Array.isArray(j.entries) ? j.entries.map(e => ({ ...e, status: e.status || "open" })) : [];
52
- return { version: 1, updatedAt: j.updatedAt ?? null, entries };
53
- } catch {
54
- return { version: 1, updatedAt: null, entries: [] };
55
- }
56
- }
57
-
58
- export function writeTicketIndexJson(cwd, indexJson, opts = {}) {
59
- const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
60
- const p = join(dir, TICKET_INDEX_FILENAME);
61
- if (opts.dryRun) return;
62
- mkdirSync(dir, { recursive: true });
63
- writeFileSync(p, JSON.stringify(indexJson, null, 2) + "\n", "utf8");
64
- }
65
-
66
- export function renderTicketListMarkdown(cwd, entries) {
67
- const sorted = [...entries].sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
68
- const latest = sorted[0] || null;
69
-
70
- const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
71
- const templatePath = join(ticketDir, TICKET_LIST_TEMPLATE_FILENAME);
72
- const template = existsSync(templatePath) ? readFileSync(templatePath, "utf8") : DEFAULT_TICKET_LIST_TEMPLATE;
73
-
74
- let latestBlock = "- No ticket entries yet.";
75
- if (latest) {
76
- const relPath = toPosixPath(relative(ticketDir, join(cwd, latest.path)));
77
- 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
- }
80
-
81
- let rows = sorted.slice(0, 30).map((e, i) => {
82
- const relPath = toPosixPath(relative(ticketDir, join(cwd, e.path)));
83
- const statusPrefix = e.status === "closed" ? "✓ " : "";
84
- const safeTitle = String(e.title || "").replace(/\|/g, '|').replace(/(\n|\\n)+/g, ' ');
85
- return `| ${i + 1} | ${statusPrefix}${safeTitle} | ${e.group} | ${e.project} | ${e.createdAt} | [open](${relPath}) |`;
86
- }).join("\n");
87
-
88
- return template
89
- .replaceAll("{{SOURCE_INDEX}}", `${TICKET_DIR_NAME}/${TICKET_INDEX_FILENAME}`)
90
- .replaceAll("{{LATEST_BLOCK}}", latestBlock)
91
- .replaceAll("{{ENTRIES_ROWS}}", rows || "| - | No entries yet | - | - | - | - |")
92
- .replaceAll("{{CMD_LIST}}", "npx deuk-agent-rule ticket list")
93
- .replaceAll("{{CMD_USE_LATEST}}", "npx deuk-agent-rule ticket use --latest");
94
- }
95
-
96
- export function writeTicketListFile(cwd, entries, opts = {}) {
97
- const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
98
- const p = join(ticketDir, TICKET_LIST_FILENAME);
99
- if (opts.dryRun) return;
100
- const body = renderTicketListMarkdown(cwd, entries);
101
- mkdirSync(ticketDir, { recursive: true });
102
- writeFileSync(p, body, "utf8");
103
- }
104
-
105
- export function appendTicketEntry(cwd, entry, opts = {}) {
106
- const indexJson = readTicketIndexJson(cwd);
107
- entry.status = entry.status || "open";
108
- const next = { version: 1, updatedAt: new Date().toISOString(), entries: [entry, ...indexJson.entries] };
109
- writeTicketIndexJson(cwd, next, opts);
110
- writeTicketListFile(cwd, next.entries, opts);
111
- }
112
-
113
- export function updateTicketEntryStatus(cwd, opts = {}) {
114
- const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts);
115
- let foundIndex = -1;
116
- const targetTopic = opts.topic ? String(opts.topic).toLowerCase() : null;
117
-
118
- if (opts.latest) {
119
- foundIndex = 0;
120
- } else if (targetTopic) {
121
- foundIndex = indexJson.entries.findIndex(e => String(e.topic).toLowerCase().includes(targetTopic));
122
- }
123
-
124
- if (foundIndex === -1) {
125
- throw new Error("No matching ticket found to update status");
126
- }
127
-
128
- const entry = indexJson.entries[foundIndex];
129
- entry.status = opts.status || "closed";
130
- entry.updatedAt = new Date().toISOString(); // optional metadata update
131
-
132
- const next = { version: indexJson.version, updatedAt: new Date().toISOString(), entries: indexJson.entries };
133
- writeTicketIndexJson(cwd, next, opts);
134
- writeTicketListFile(cwd, next.entries, opts);
135
- return entry;
136
- }
137
-
138
- export function collectTicketMarkdownFiles(dir, out = []) {
139
- if (!existsSync(dir)) return out;
140
- for (const ent of readdirSync(dir, { withFileTypes: true })) {
141
- const abs = join(dir, ent.name);
142
- if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
143
- else if (ent.isFile() && /\.md$/i.test(ent.name)) out.push(abs);
144
- }
145
- return out;
146
- }
147
-
148
- export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
149
- 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
- });
155
-
156
- let dirty = false;
157
- const existingPaths = new Set(indexJson.entries.map(e => toPosixPath(e.path)));
158
-
159
- for (let i = 0; i < files.length; i++) {
160
- const abs = files[i];
161
- 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
- }
179
- }
180
-
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) {
186
- dirty = true;
187
- }
188
-
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 };
192
- writeTicketIndexJson(cwd, next, opts);
193
- writeTicketListFile(cwd, next.entries, opts);
194
- return next;
195
- }
196
-
197
- return indexJson;
198
- }
199
-
200
- export function parseLegacyTicketMeta(legacyBody) {
201
- // Supports ## Task: ... or # Plan: ... or # Implementation Plan: ...
202
- const titleMatch = legacyBody.match(/^(?:##\s+Task:|#\s+Plan:|#\s+Implementation Plan:)\s*(.+)$/m);
203
- const title = titleMatch ? titleMatch[1].trim() : "Migrated legacy plan";
204
-
205
- let group = "sub";
206
- const lower = legacyBody.toLowerCase();
207
- if (lower.includes("discussion")) group = "discussion";
208
- else if (lower.includes("main")) group = "main";
209
-
210
- return { title, group, project: detectProjectFromBody(legacyBody) };
211
- }
212
-
213
- export function getLegacyMigrationCandidate(cwd) {
214
- const candidateFiles = ["LATEST.md", "implementation_plan.md"];
215
- const candidateDirs = [cwd, join(cwd, TICKET_DIR_NAME), join(cwd, "ticket")];
216
- for (const dir of candidateDirs) {
217
- for (const file of candidateFiles) {
218
- const p = join(dir, file);
219
- if (existsSync(p)) {
220
- const body = readFileSync(p, "utf8");
221
- // 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))) {
223
- return { latestPath: p, body };
224
- }
225
- }
226
- }
227
- }
228
- return null;
229
- }
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "fs";
2
+ import { basename, dirname, join, relative } from "path";
3
+ import { toPosixPath, toRepoRelativePath, toSlug, formatTimestampForFile, makeEntryId, detectProjectFromBody, deriveTopicFromBaseName } from "./cli-utils.mjs";
4
+
5
+ export const TICKET_DIR_NAME = ".deuk-agent-ticket";
6
+ export const TICKET_INDEX_FILENAME = "INDEX.json";
7
+ export const TICKET_LIST_FILENAME = "TICKET_LIST.md";
8
+ export const TICKET_LIST_TEMPLATE_FILENAME = "TICKET_LIST.template.md";
9
+
10
+ const DEFAULT_TICKET_LIST_TEMPLATE = `# Ticket List
11
+
12
+ > Source index: \`{{SOURCE_INDEX}}\`
13
+
14
+ ## Latest
15
+
16
+ {{LATEST_BLOCK}}
17
+
18
+ ## Entries
19
+
20
+ | # | Title | Group | Project | Created | Path |
21
+ |---|---|---|---|---|---|
22
+ {{ENTRIES_ROWS}}
23
+
24
+ ## Commands
25
+
26
+ \`\`\`bash
27
+ {{CMD_LIST}}
28
+ {{CMD_USE_LATEST}}
29
+ \`\`\`
30
+ `;
31
+
32
+ export function detectConsumerTicketDir(startDir, opts = {}) {
33
+ let curr = startDir;
34
+ while (curr && curr !== dirname(curr)) {
35
+ const p = join(curr, TICKET_DIR_NAME);
36
+ if (existsSync(p)) return p;
37
+ curr = dirname(curr);
38
+ }
39
+ // If not found and creation allowed (init), return default local path.
40
+ // Otherwise return null to indicate no ticket system found.
41
+ return opts.createIfMissing ? join(startDir, TICKET_DIR_NAME) : null;
42
+ }
43
+
44
+ export function readTicketIndexJson(cwd) {
45
+ const dir = detectConsumerTicketDir(cwd);
46
+ if (!dir) return { version: 1, updatedAt: null, entries: [] };
47
+ const p = join(dir, TICKET_INDEX_FILENAME);
48
+ if (!existsSync(p)) return { version: 1, updatedAt: null, entries: [] };
49
+ try {
50
+ const j = JSON.parse(readFileSync(p, "utf8"));
51
+ const entries = Array.isArray(j.entries) ? j.entries.map(e => ({ ...e, status: e.status || "open" })) : [];
52
+ return { version: 1, updatedAt: j.updatedAt ?? null, entries };
53
+ } catch {
54
+ return { version: 1, updatedAt: null, entries: [] };
55
+ }
56
+ }
57
+
58
+ export function writeTicketIndexJson(cwd, indexJson, opts = {}) {
59
+ const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
60
+ const p = join(dir, TICKET_INDEX_FILENAME);
61
+ if (opts.dryRun) return;
62
+ mkdirSync(dir, { recursive: true });
63
+ writeFileSync(p, JSON.stringify(indexJson, null, 2) + "\n", "utf8");
64
+ }
65
+
66
+ export function renderTicketListMarkdown(cwd, entries) {
67
+ const sorted = [...entries].sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
68
+ const latest = sorted[0] || null;
69
+
70
+ const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
71
+ const templatePath = join(ticketDir, TICKET_LIST_TEMPLATE_FILENAME);
72
+ const template = existsSync(templatePath) ? readFileSync(templatePath, "utf8") : DEFAULT_TICKET_LIST_TEMPLATE;
73
+
74
+ let latestBlock = "- No ticket entries yet.";
75
+ if (latest) {
76
+ const relPath = toPosixPath(relative(ticketDir, join(cwd, latest.path)));
77
+ 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
+ }
80
+
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");
87
+
88
+ return template
89
+ .replaceAll("{{SOURCE_INDEX}}", `${TICKET_DIR_NAME}/${TICKET_INDEX_FILENAME}`)
90
+ .replaceAll("{{LATEST_BLOCK}}", latestBlock)
91
+ .replaceAll("{{ENTRIES_ROWS}}", rows || "| - | No entries yet | - | - | - | - |")
92
+ .replaceAll("{{CMD_LIST}}", "npx deuk-agent-rule ticket list")
93
+ .replaceAll("{{CMD_USE_LATEST}}", "npx deuk-agent-rule ticket use --latest");
94
+ }
95
+
96
+ export function writeTicketListFile(cwd, entries, opts = {}) {
97
+ const ticketDir = detectConsumerTicketDir(cwd, { createIfMissing: true });
98
+ const p = join(ticketDir, TICKET_LIST_FILENAME);
99
+ if (opts.dryRun) return;
100
+ const body = renderTicketListMarkdown(cwd, entries);
101
+ mkdirSync(ticketDir, { recursive: true });
102
+ writeFileSync(p, body, "utf8");
103
+ }
104
+
105
+ export function appendTicketEntry(cwd, entry, opts = {}) {
106
+ const indexJson = readTicketIndexJson(cwd);
107
+ entry.status = entry.status || "open";
108
+ const next = { version: 1, updatedAt: new Date().toISOString(), entries: [entry, ...indexJson.entries] };
109
+ writeTicketIndexJson(cwd, next, opts);
110
+ writeTicketListFile(cwd, next.entries, opts);
111
+ }
112
+
113
+ export function updateTicketEntryStatus(cwd, opts = {}) {
114
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts);
115
+ let foundIndex = -1;
116
+ const targetTopic = opts.topic ? String(opts.topic).toLowerCase() : null;
117
+
118
+ if (opts.latest) {
119
+ foundIndex = 0;
120
+ } else if (targetTopic) {
121
+ foundIndex = indexJson.entries.findIndex(e => String(e.topic).toLowerCase().includes(targetTopic));
122
+ }
123
+
124
+ if (foundIndex === -1) {
125
+ throw new Error("No matching ticket found to update status");
126
+ }
127
+
128
+ const entry = indexJson.entries[foundIndex];
129
+ entry.status = opts.status || "closed";
130
+ entry.updatedAt = new Date().toISOString(); // optional metadata update
131
+
132
+ const next = { version: indexJson.version, updatedAt: new Date().toISOString(), entries: indexJson.entries };
133
+ writeTicketIndexJson(cwd, next, opts);
134
+ writeTicketListFile(cwd, next.entries, opts);
135
+ return entry;
136
+ }
137
+
138
+ export function collectTicketMarkdownFiles(dir, out = []) {
139
+ if (!existsSync(dir)) return out;
140
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
141
+ const abs = join(dir, ent.name);
142
+ if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
143
+ else if (ent.isFile() && /\.md$/i.test(ent.name)) out.push(abs);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
149
+ 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
+ });
155
+
156
+ let dirty = false;
157
+ const existingPaths = new Set(indexJson.entries.map(e => toPosixPath(e.path)));
158
+
159
+ for (let i = 0; i < files.length; i++) {
160
+ const abs = files[i];
161
+ 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
+ }
179
+ }
180
+
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) {
186
+ dirty = true;
187
+ }
188
+
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 };
192
+ writeTicketIndexJson(cwd, next, opts);
193
+ writeTicketListFile(cwd, next.entries, opts);
194
+ return next;
195
+ }
196
+
197
+ return indexJson;
198
+ }
199
+
200
+ export function parseLegacyTicketMeta(legacyBody) {
201
+ // Supports ## Task: ... or # Plan: ... or # Implementation Plan: ...
202
+ const titleMatch = legacyBody.match(/^(?:##\s+Task:|#\s+Plan:|#\s+Implementation Plan:)\s*(.+)$/m);
203
+ const title = titleMatch ? titleMatch[1].trim() : "Migrated legacy plan";
204
+
205
+ let group = "sub";
206
+ const lower = legacyBody.toLowerCase();
207
+ if (lower.includes("discussion")) group = "discussion";
208
+ else if (lower.includes("main")) group = "main";
209
+
210
+ return { title, group, project: detectProjectFromBody(legacyBody) };
211
+ }
212
+
213
+ export function getLegacyMigrationCandidate(cwd) {
214
+ const candidateFiles = ["LATEST.md", "implementation_plan.md"];
215
+ const candidateDirs = [cwd, join(cwd, TICKET_DIR_NAME), join(cwd, "ticket")];
216
+ for (const dir of candidateDirs) {
217
+ for (const file of candidateFiles) {
218
+ const p = join(dir, file);
219
+ if (existsSync(p)) {
220
+ const body = readFileSync(p, "utf8");
221
+ // 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))) {
223
+ return { latestPath: p, body };
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return null;
229
+ }
@@ -1,82 +1,82 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { basename, dirname, join, relative } from "path";
3
-
4
- export function toPosixPath(p) {
5
- return p.replace(/\\/g, "/");
6
- }
7
-
8
- export function toRepoRelativePath(cwd, absPath) {
9
- return toPosixPath(relative(cwd, absPath));
10
- }
11
-
12
- export function toSlug(input) {
13
- return String(input || "")
14
- .toLowerCase()
15
- .replace(/[^\p{L}\p{N}]+/gu, "-")
16
- .replace(/^-+|-+$/g, "") || "ticket";
17
- }
18
-
19
- export function formatTimestampForFile(d = new Date()) {
20
- const y = d.getFullYear();
21
- const m = String(d.getMonth() + 1).padStart(2, "0");
22
- const day = String(d.getDate()).padStart(2, "0");
23
- const hh = String(d.getHours()).padStart(2, "0");
24
- const mm = String(d.getMinutes()).padStart(2, "0");
25
- const ss = String(d.getSeconds()).padStart(2, "0");
26
- return `${y}${m}${day}-${hh}${mm}${ss}`;
27
- }
28
-
29
- export function makeEntryId() {
30
- return `ticket_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
31
- }
32
-
33
- export function detectProjectFromBody(body) {
34
- const content = String(body || "");
35
- const metaMatch = content.match(/^project:\s*(.+)$/mi);
36
- if (metaMatch) return metaMatch[1].trim();
37
-
38
- const headerMatch = content.match(/^##?\s+Project:\s*(.+)$/mi);
39
- if (headerMatch) return headerMatch[1].trim();
40
-
41
- const legacyMatch = content.match(/\b(YourProject)\b/i);
42
- return legacyMatch ? legacyMatch[1] : "global";
43
- }
44
-
45
- export function deriveTopicFromBaseName(baseName) {
46
- const raw = String(baseName || "").replace(/\.md$/i, "");
47
- const topic = raw.replace(/-\d{8}-\d{6}$/i, "");
48
- return toSlug(topic || raw || "ticket");
49
- }
50
-
51
- export function resolveReferencedTicketPath(opts) {
52
- if (!opts.ref) return null;
53
- const refAbs = join(opts.cwd, opts.ref);
54
- if (!existsSync(refAbs)) {
55
- throw new Error("--ref file not found: " + opts.ref);
56
- }
57
- return toRepoRelativePath(opts.cwd, refAbs);
58
- }
59
-
60
- export function inferRefTitleAndTopic(opts) {
61
- if (!opts.ref) return null;
62
- const refAbs = join(opts.cwd, opts.ref);
63
-
64
- let body = "";
65
- try {
66
- body = readFileSync(refAbs, "utf8");
67
- } catch {
68
- body = "";
69
- }
70
-
71
- const taskTitleMatch = body.match(/^##\s+Task:\s*(.+)$/m);
72
- const headingMatch = body.match(/^#\s+(.+)$/m);
73
- const base = basename(refAbs).replace(/\.[^.]+$/, "");
74
-
75
- const title = (taskTitleMatch && taskTitleMatch[1]) || (headingMatch && headingMatch[1]) || base;
76
- const topic = toSlug(title || base);
77
-
78
- return {
79
- title: String(title || base).trim(),
80
- topic,
81
- };
82
- }
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { basename, dirname, join, relative } from "path";
3
+
4
+ export function toPosixPath(p) {
5
+ return p.replace(/\\/g, "/");
6
+ }
7
+
8
+ export function toRepoRelativePath(cwd, absPath) {
9
+ return toPosixPath(relative(cwd, absPath));
10
+ }
11
+
12
+ export function toSlug(input) {
13
+ return String(input || "")
14
+ .toLowerCase()
15
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
16
+ .replace(/^-+|-+$/g, "") || "ticket";
17
+ }
18
+
19
+ export function formatTimestampForFile(d = new Date()) {
20
+ const y = d.getFullYear();
21
+ const m = String(d.getMonth() + 1).padStart(2, "0");
22
+ const day = String(d.getDate()).padStart(2, "0");
23
+ const hh = String(d.getHours()).padStart(2, "0");
24
+ const mm = String(d.getMinutes()).padStart(2, "0");
25
+ const ss = String(d.getSeconds()).padStart(2, "0");
26
+ return `${y}${m}${day}-${hh}${mm}${ss}`;
27
+ }
28
+
29
+ export function makeEntryId() {
30
+ return `ticket_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
31
+ }
32
+
33
+ export function detectProjectFromBody(body) {
34
+ const content = String(body || "");
35
+ const metaMatch = content.match(/^project:\s*(.+)$/mi);
36
+ if (metaMatch) return metaMatch[1].trim();
37
+
38
+ const headerMatch = content.match(/^##?\s+Project:\s*(.+)$/mi);
39
+ if (headerMatch) return headerMatch[1].trim();
40
+
41
+ const legacyMatch = content.match(/\b(YourProject)\b/i);
42
+ return legacyMatch ? legacyMatch[1] : "global";
43
+ }
44
+
45
+ export function deriveTopicFromBaseName(baseName) {
46
+ const raw = String(baseName || "").replace(/\.md$/i, "");
47
+ const topic = raw.replace(/-\d{8}-\d{6}$/i, "");
48
+ return toSlug(topic || raw || "ticket");
49
+ }
50
+
51
+ export function resolveReferencedTicketPath(opts) {
52
+ if (!opts.ref) return null;
53
+ const refAbs = join(opts.cwd, opts.ref);
54
+ if (!existsSync(refAbs)) {
55
+ throw new Error("--ref file not found: " + opts.ref);
56
+ }
57
+ return toRepoRelativePath(opts.cwd, refAbs);
58
+ }
59
+
60
+ export function inferRefTitleAndTopic(opts) {
61
+ if (!opts.ref) return null;
62
+ const refAbs = join(opts.cwd, opts.ref);
63
+
64
+ let body = "";
65
+ try {
66
+ body = readFileSync(refAbs, "utf8");
67
+ } catch {
68
+ body = "";
69
+ }
70
+
71
+ const taskTitleMatch = body.match(/^##\s+Task:\s*(.+)$/m);
72
+ const headingMatch = body.match(/^#\s+(.+)$/m);
73
+ const base = basename(refAbs).replace(/\.[^.]+$/, "");
74
+
75
+ const title = (taskTitleMatch && taskTitleMatch[1]) || (headingMatch && headingMatch[1]) || base;
76
+ const topic = toSlug(title || base);
77
+
78
+ return {
79
+ title: String(title || base).trim(),
80
+ topic,
81
+ };
82
+ }