deuk-agent-flow 4.0.37 → 5.0.2

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.
Files changed (78) hide show
  1. package/CHANGELOG.ko.md +282 -0
  2. package/CHANGELOG.md +788 -120
  3. package/LICENSE +0 -0
  4. package/README.ko.md +97 -17
  5. package/README.md +108 -25
  6. package/bin/deuk-agent-flow.js +11 -13
  7. package/bin/deuk-agent-rule.js +1 -1
  8. package/bundled/README.md +3 -0
  9. package/bundled/deuk-agent-flow.vsix +0 -0
  10. package/core-rules/AGENTS.md +30 -118
  11. package/docs/architecture.ko.md +155 -2
  12. package/docs/architecture.md +155 -2
  13. package/docs/assets/agentflow-panel-skills.png +0 -0
  14. package/docs/assets/agentflow-panel.png +0 -0
  15. package/docs/how-it-works.ko.md +109 -52
  16. package/docs/how-it-works.md +128 -71
  17. package/docs/principles.ko.md +68 -68
  18. package/docs/principles.md +68 -68
  19. package/docs/usage-guide.ko.md +251 -212
  20. package/package.json +41 -34
  21. package/scripts/bundle-vscode-vsix.ts +67 -0
  22. package/scripts/{cli-args.mjs → cli-args.ts} +49 -12
  23. package/scripts/cli-init-commands.ts +99 -0
  24. package/scripts/cli-init-logic.ts +46 -0
  25. package/scripts/{cli-prompts.mjs → cli-prompts.ts} +19 -34
  26. package/scripts/{cli-rule-compiler.mjs → cli-rule-compiler.ts} +128 -112
  27. package/scripts/cli-skill-commands.ts +707 -0
  28. package/scripts/{cli-telemetry-commands.mjs → cli-telemetry-commands.ts} +25 -22
  29. package/scripts/cli-ticket-command-shared.ts +4 -0
  30. package/scripts/cli-ticket-commands.ts +3723 -0
  31. package/scripts/cli-ticket-index.ts +283 -0
  32. package/scripts/{cli-ticket-migration.mjs → cli-ticket-migration.ts} +44 -68
  33. package/scripts/cli-ticket-parser.ts +100 -0
  34. package/scripts/{cli-usage-commands.mjs → cli-usage-commands.ts} +325 -326
  35. package/scripts/cli-utils.ts +1560 -0
  36. package/scripts/cli.ts +695 -0
  37. package/scripts/{lint-md.mjs → lint-md.ts} +32 -42
  38. package/scripts/lint-rules.ts +203 -0
  39. package/scripts/{merge-logic.mjs → merge-logic.ts} +44 -44
  40. package/scripts/{plan-parser.mjs → plan-parser.ts} +53 -53
  41. package/templates/MODULE_RULE_TEMPLATE.md +11 -11
  42. package/templates/PROJECT_RULE.md +46 -47
  43. package/templates/TICKET_TEMPLATE.ko.md +48 -44
  44. package/templates/TICKET_TEMPLATE.md +48 -44
  45. package/templates/project-memory.md +19 -0
  46. package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +25 -23
  47. package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -19
  48. package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -26
  49. package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -30
  50. package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -26
  51. package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -15
  52. package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -34
  53. package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -32
  54. package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -33
  55. package/templates/rules.d/deukcontext-mcp.md +31 -31
  56. package/templates/rules.d/platform-coexistence.md +29 -29
  57. package/templates/skills/context-recall/SKILL.md +3 -1
  58. package/templates/skills/doc-sync/SKILL.md +111 -0
  59. package/templates/skills/generated-file-guard/SKILL.md +3 -1
  60. package/templates/skills/persona-maid/SKILL.md +65 -0
  61. package/templates/skills/project-pilot/SKILL.md +13 -52
  62. package/templates/skills/safe-refactor/SKILL.md +3 -1
  63. package/templates/skills/ticket-status-surface/SKILL.md +17 -0
  64. package/core-rules/GEMINI.md +0 -7
  65. package/docs/badges/npm-downloads.json +0 -8
  66. package/scripts/cli-init-commands.mjs +0 -1750
  67. package/scripts/cli-init-logic.mjs +0 -64
  68. package/scripts/cli-skill-commands.mjs +0 -201
  69. package/scripts/cli-ticket-commands.mjs +0 -2427
  70. package/scripts/cli-ticket-index.mjs +0 -298
  71. package/scripts/cli-ticket-parser.mjs +0 -209
  72. package/scripts/cli-utils.mjs +0 -602
  73. package/scripts/cli.mjs +0 -256
  74. package/scripts/lint-rules.mjs +0 -196
  75. package/scripts/publish-dual-npm.mjs +0 -141
  76. package/scripts/smoke-npm-docker.mjs +0 -102
  77. package/scripts/smoke-npm-local.mjs +0 -109
  78. package/scripts/update-download-badge.mjs +0 -103
@@ -1,298 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "fs";
2
- import { basename, dirname, join, resolve } from "path";
3
- import { hostname as osHostname } from "os";
4
- import {
5
- AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_INDEX_FILENAME,
6
- requireNonEmptySlug, findFileRecursively, toRepoRelativePath, detectConsumerTicketDir, computeTicketPath, normalizeTicketGroup
7
- } from "./cli-utils.mjs";
8
-
9
- const TICKET_ARCHIVE_INDEX_FILENAME = "INDEX.archive.json";
10
- const TICKET_ARCHIVE_INDEX_PREFIX = "INDEX.archive.";
11
- const ARCHIVE_INDEX_RETENTION_MONTHS = 12;
12
- const ARCHIVE_INDEX_MONTH_RE = /^INDEX\.archive\.(\d{4}-\d{2})\.json$/;
13
- const ARCHIVE_INDEX_LEGACY_RE = /^INDEX\.archive\.json$/;
14
-
15
- function parseArchiveMonth(value) {
16
- const match = String(value || "").match(/^(\d{4})-(\d{2})$/);
17
- if (!match) return null;
18
- const year = Number(match[1]);
19
- const month = Number(match[2]);
20
- if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return null;
21
- return { year, month, yearMonth: `${match[1]}-${match[2]}` };
22
- }
23
-
24
- function monthDistance(fromYearMonth, toDate = new Date()) {
25
- const parsed = parseArchiveMonth(fromYearMonth);
26
- if (!parsed) return null;
27
- return (toDate.getUTCFullYear() * 12 + toDate.getUTCMonth()) - ((parsed.year * 12) + (parsed.month - 1));
28
- }
29
-
30
- function resolveArchivePartition(entry, now = new Date()) {
31
- const fromEntry = parseArchiveMonth(entry?.archiveYearMonth);
32
- if (fromEntry) {
33
- return {
34
- yearMonth: fromEntry.yearMonth
35
- };
36
- }
37
-
38
- const source = String(entry?.createdAt || entry?.updatedAt || "");
39
- const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
40
- if (match) {
41
- return { yearMonth: `${match[1]}-${match[2]}` };
42
- }
43
-
44
- return {
45
- yearMonth: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
46
- };
47
- }
48
-
49
- function shouldRetainArchiveEntry(entry, now = new Date()) {
50
- const partition = resolveArchivePartition(entry, now);
51
- const distance = monthDistance(partition.yearMonth, now);
52
- if (distance === null) return true;
53
- return distance <= ARCHIVE_INDEX_RETENTION_MONTHS;
54
- }
55
-
56
- function archiveIndexFilePath(dir, yearMonth = null) {
57
- return join(dir, yearMonth ? `${TICKET_ARCHIVE_INDEX_PREFIX}${yearMonth}.json` : TICKET_ARCHIVE_INDEX_FILENAME);
58
- }
59
-
60
- function listArchiveIndexFiles(dir) {
61
- if (!existsSync(dir)) return [];
62
- return readdirSync(dir, { withFileTypes: true })
63
- .filter(ent => ent.isFile())
64
- .map(ent => join(dir, ent.name))
65
- .filter(abs => ARCHIVE_INDEX_LEGACY_RE.test(basename(abs)) || ARCHIVE_INDEX_MONTH_RE.test(basename(abs)))
66
- .sort((a, b) => {
67
- const aBase = basename(a);
68
- const bBase = basename(b);
69
- const aMatch = aBase.match(ARCHIVE_INDEX_MONTH_RE);
70
- const bMatch = bBase.match(ARCHIVE_INDEX_MONTH_RE);
71
- if (aMatch && !bMatch) return 1;
72
- if (!aMatch && bMatch) return -1;
73
- if (!aMatch && !bMatch) return aBase.localeCompare(bBase);
74
- return aMatch[1].localeCompare(bMatch[1]);
75
- });
76
- }
77
-
78
- function parseIndexFile(absPath) {
79
- if (!existsSync(absPath)) {
80
- return { version: 1, updatedAt: null, activeTicketId: null, entries: [] };
81
- }
82
- try {
83
- const j = JSON.parse(readFileSync(absPath, "utf8"));
84
- const entries = Array.isArray(j.entries) ? j.entries.map(e => {
85
- const entry = { ...e, status: e.status || "open", group: normalizeTicketGroup(e.group, "sub") };
86
- entry.path = computeTicketPath(entry);
87
- return entry;
88
- }) : [];
89
- return {
90
- version: j.version || 1,
91
- updatedAt: j.updatedAt ?? null,
92
- activeTicketId: j.activeTicketId ?? null,
93
- entries
94
- };
95
- } catch (err) {
96
- console.error(`[ERROR] Failed to parse ${basename(absPath)} at ${absPath}:`, err.message);
97
- return { version: 1, updatedAt: null, activeTicketId: null, entries: [], _corrupt: true };
98
- }
99
- }
100
-
101
- function splitEntriesForStorage(entries = []) {
102
- const activeEntries = [];
103
- const archiveEntries = [];
104
- for (const entry of entries) {
105
- const status = String(entry?.status || "open").toLowerCase();
106
- if (status === "open" || status === "active") {
107
- activeEntries.push(entry);
108
- } else {
109
- archiveEntries.push(entry);
110
- }
111
- }
112
- return { activeEntries, archiveEntries };
113
- }
114
-
115
- function partitionArchiveEntries(entries = []) {
116
- const retained = [];
117
- const retired = [];
118
- for (const entry of entries) {
119
- if (shouldRetainArchiveEntry(entry)) retained.push(entry);
120
- else retired.push(entry);
121
- }
122
- return { retained, retired };
123
- }
124
-
125
- function mergeIndexEntries(primaryEntries = [], archiveEntries = []) {
126
- const merged = new Map();
127
- for (const entry of primaryEntries || []) {
128
- if (entry?.id) merged.set(entry.id, entry);
129
- }
130
- for (const entry of archiveEntries || []) {
131
- if (entry?.id) merged.set(entry.id, entry);
132
- }
133
- return Array.from(merged.values());
134
- }
135
-
136
- export function readTicketIndexJson(cwd) {
137
- const dir = detectConsumerTicketDir(cwd);
138
- if (!dir) return { version: 1, updatedAt: null, entries: [] };
139
- const mainPath = join(dir, TICKET_INDEX_FILENAME);
140
- const main = parseIndexFile(mainPath);
141
- const archiveFiles = listArchiveIndexFiles(dir).map(parseIndexFile);
142
-
143
- const archiveEntries = archiveFiles.flatMap(file => file.entries || []);
144
- const entries = mergeIndexEntries(main.entries, archiveEntries).map(entry => {
145
- const next = { ...entry, status: entry.status || "open", group: normalizeTicketGroup(entry.group, "sub") };
146
- next.path = computeTicketPath(next);
147
- return next;
148
- });
149
-
150
- return {
151
- version: main.version || archiveFiles[0]?.version || 1,
152
- updatedAt: main.updatedAt ?? archiveFiles[0]?.updatedAt ?? null,
153
- activeTicketId: main.activeTicketId ?? null,
154
- entries,
155
- _corrupt: Boolean(main._corrupt || archiveFiles.some(file => file._corrupt))
156
- };
157
- }
158
-
159
- export function writeTicketIndexJson(cwd, indexJson, opts = {}) {
160
- if (indexJson._corrupt && !opts.force) {
161
- console.error(`[ABORT] Refusing to overwrite potentially corrupt ${TICKET_INDEX_FILENAME}. Use --force to override.`);
162
- return;
163
- }
164
- const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
165
- const p = join(dir, TICKET_INDEX_FILENAME);
166
- if (opts.dryRun) return;
167
- mkdirSync(dir, { recursive: true });
168
-
169
- const out = { ...indexJson };
170
- delete out._corrupt;
171
-
172
- // Strip physical path snapshots before saving to enforce state-driven resolution
173
- const entries = Array.isArray(out.entries) ? out.entries : [];
174
- const { activeEntries, archiveEntries } = splitEntriesForStorage(entries);
175
- out.entries = activeEntries.map(e => {
176
- const { path, archiveDay, ...clean } = e;
177
- return clean;
178
- });
179
- out.activeTicketId = out.activeTicketId || activeEntries.find(e => e.status === "active")?.id || activeEntries.find(e => e.status === "open")?.id || null;
180
- writeFileSync(p, JSON.stringify(out, null, 2) + "\n", "utf8");
181
-
182
- const archiveFiles = listArchiveIndexFiles(dir);
183
- const retainedArchiveEntries = partitionArchiveEntries(archiveEntries);
184
- const archiveBuckets = new Map();
185
-
186
- for (const entry of retainedArchiveEntries.retained) {
187
- const partition = resolveArchivePartition(entry);
188
- const bucket = archiveBuckets.get(partition.yearMonth) || [];
189
- bucket.push({ ...entry, archiveYearMonth: partition.yearMonth });
190
- archiveBuckets.set(partition.yearMonth, bucket);
191
- }
192
-
193
- const desiredArchiveFiles = new Set();
194
- for (const [yearMonth, bucket] of archiveBuckets.entries()) {
195
- const archiveOut = {
196
- version: out.version || 1,
197
- updatedAt: out.updatedAt || new Date().toISOString(),
198
- activeTicketId: null,
199
- entries: bucket.map(e => {
200
- const { path, archiveDay, ...clean } = e;
201
- return clean;
202
- })
203
- };
204
- const archivePath = archiveIndexFilePath(dir, yearMonth);
205
- desiredArchiveFiles.add(archivePath);
206
- writeFileSync(archivePath, JSON.stringify(archiveOut, null, 2) + "\n", "utf8");
207
- }
208
-
209
- for (const filePath of archiveFiles) {
210
- if (!desiredArchiveFiles.has(filePath)) {
211
- unlinkSync(filePath);
212
- }
213
- }
214
-
215
- if (retainedArchiveEntries.retired.length > 0) {
216
- console.log(`[GC] Dropped ${retainedArchiveEntries.retired.length} archived entries outside the ${ARCHIVE_INDEX_RETENTION_MONTHS}-month retention window.`);
217
- }
218
- }
219
-
220
- export function getHostnameSlug() {
221
- try {
222
- const slug = osHostname().toLowerCase().replace(/[^a-z0-9\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
223
- return slug.slice(0, 8).replace(/-$/, '') || 'local';
224
- } catch {
225
- return 'local';
226
- }
227
- }
228
-
229
- export function computeNextTicketNumber(existingEntries) {
230
- const hostname = getHostnameSlug();
231
- const newRe = /^(\d{3,4})-/;
232
- let max = 0;
233
- for (const e of (existingEntries || [])) {
234
- const id = String(e.id || '');
235
- const m = id.match(newRe);
236
- if (m) {
237
- const n = parseInt(m[1], 10);
238
- if (n > max && n < 10000) max = n;
239
- }
240
- }
241
- return { num: max + 1, hostname };
242
- }
243
-
244
- export function generateTicketId(topicSlug, existingEntries) {
245
- const hostname = getHostnameSlug();
246
- const slug = requireNonEmptySlug(topicSlug, "ticket topic");
247
- const match = slug.match(/^(\d{3,4})-(.*)/);
248
- if (match) {
249
- const numStr = match[1];
250
- const restSlug = match[2].slice(0, 32);
251
- return `${numStr}-${restSlug}-${hostname}`;
252
- }
253
- const { num } = computeNextTicketNumber(existingEntries);
254
- const numStr = String(num).padStart(3, '0');
255
- const finalSlug = slug.slice(0, 32);
256
- return `${numStr}-${finalSlug}-${hostname}`;
257
- }
258
-
259
- export function syncActiveTicketId(cwd, opts = {}) {
260
- const index = readTicketIndexJson(cwd);
261
- const activeEntries = index.entries.filter(e => e.status === "active");
262
- const openEntries = index.entries.filter(e => e.status === "open");
263
- const newestFirst = (a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || ""));
264
- const activeEntry = activeEntries.sort(newestFirst)[0] || openEntries.sort(newestFirst)[0] || null;
265
-
266
- const ticketDir = detectConsumerTicketDir(cwd);
267
- if (!ticketDir) return;
268
- if (opts.dryRun) return;
269
-
270
- const activeId = activeEntry ? activeEntry.id : null;
271
- if (index.activeTicketId !== activeId) {
272
- writeTicketIndexJson(cwd, { ...index, activeTicketId: activeId });
273
- }
274
-
275
- const legacyLatestPath = join(ticketDir, "LATEST.md");
276
- const pointerPathMd = join(ticketDir, "ACTIVE_TICKET.md");
277
- const pointerPathJson = join(ticketDir, "ACTIVE_TICKET.json");
278
-
279
- for (const p of [legacyLatestPath, pointerPathMd, pointerPathJson]) {
280
- if (existsSync(p)) {
281
- unlinkSync(p);
282
- }
283
- }
284
- }
285
-
286
- export async function syncToPipeline(url, data) {
287
- try {
288
- const response = await fetch(url, {
289
- method: "POST",
290
- headers: { "Content-Type": "application/json" },
291
- body: JSON.stringify(data),
292
- signal: AbortSignal?.timeout ? AbortSignal.timeout(3000) : undefined
293
- });
294
- return response.ok;
295
- } catch (err) {
296
- return false;
297
- }
298
- }
@@ -1,209 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
2
- import { basename, dirname, join } from "path";
3
- import {
4
- AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_LIST_FILENAME,
5
- toPosixPath, toRepoRelativePath, detectProjectFromBody, deriveTopicFromBaseName, normalizeTicketGroup,
6
- parseFrontMatter, stringifyFrontMatter, discoverAllWorkspaces, detectConsumerTicketDir,
7
- ARCHIVE_YEAR_MONTH_RE, ARCHIVE_DAY_RE
8
- } from "./cli-utils.mjs";
9
- import { readTicketIndexJson, writeTicketIndexJson } from "./cli-ticket-index.mjs";
10
-
11
- export function collectTicketMarkdownFiles(dir, out = []) {
12
- if (!existsSync(dir)) return out;
13
- for (const ent of readdirSync(dir, { withFileTypes: true })) {
14
- const abs = join(dir, ent.name);
15
- if (ent.name === "node_modules" || ent.name === ".git") continue;
16
- if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
17
- else if (ent.isFile() && /\.md$/i.test(ent.name)) {
18
- const base = ent.name;
19
- if (base === "LATEST.md" || base === TICKET_LIST_FILENAME || base === "ACTIVE_TICKET.md") continue;
20
- out.push(abs);
21
- }
22
- }
23
- return out;
24
- }
25
-
26
- export function discoverAllTicketDirs(baseCwd, out = []) {
27
- return discoverAllWorkspaces(baseCwd, undefined, new Set(out));
28
- }
29
-
30
- export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts = {}) {
31
- const indexJson = readTicketIndexJson(cwd);
32
-
33
- if (indexJson.entries.length > 0 && !opts.force && !opts.rebuild) {
34
- return indexJson;
35
- }
36
-
37
- const ticketDir = detectConsumerTicketDir(cwd);
38
- if (!ticketDir) return indexJson;
39
-
40
- // Strictly scan only the official ticket system directory and its subdirectories
41
- const ticketDirs = [ticketDir];
42
-
43
-
44
- if (ticketDirs.length === 0) return indexJson;
45
-
46
- const files = [];
47
- for (const dir of ticketDirs) {
48
- collectTicketMarkdownFiles(dir, files);
49
- }
50
-
51
- let dirty = false;
52
- const newEntries = [];
53
-
54
- for (let i = 0; i < files.length; i++) {
55
- const entry = processTicketFile(files[i], cwd, indexJson, opts);
56
- if (entry) newEntries.push(entry);
57
- }
58
-
59
- if (JSON.stringify(indexJson.entries) !== JSON.stringify(newEntries)) {
60
- dirty = true;
61
- }
62
-
63
- if (dirty || opts.force || opts.rebuild) {
64
- newEntries.sort((a,b) => String(b.createdAt||"").localeCompare(String(a.createdAt||"")));
65
- const next = { version: 1, updatedAt: new Date().toISOString(), entries: newEntries };
66
- writeTicketIndexJson(cwd, next, opts);
67
- if (opts.rebuild) console.log(`[REBUILD] INDEX.json rebuilt with ${newEntries.length} entries.`);
68
- return next;
69
- }
70
-
71
- return indexJson;
72
- }
73
-
74
- function processTicketFile(abs, cwd, indexJson, opts) {
75
- const rel = toPosixPath(toRepoRelativePath(cwd, abs));
76
- const filename = basename(abs);
77
- const idFromFilename = filename.replace(/\.md$/i, "");
78
- const isAlreadyInArchive = rel.includes("/archive/");
79
- const storage = parseTicketStorage(rel, isAlreadyInArchive, abs);
80
- const group = storage.group;
81
-
82
- // Optimization: If entry already exists in index and not forced, reuse metadata to save I/O & tokens
83
- const existing = indexJson.entries.find(e => e.id === idFromFilename);
84
- if (existing && !opts.force) {
85
- return {
86
- ...existing,
87
- group,
88
- archiveYearMonth: storage.archiveYearMonth || existing.archiveYearMonth,
89
- status: isAlreadyInArchive ? "archived" : (existing.status === "archived" ? "open" : existing.status),
90
- updatedAt: statSync(abs).mtime.toISOString()
91
- };
92
- }
93
-
94
- // New or forced entry: Parse file content
95
- let meta = {}, content = "";
96
- try {
97
- const body = readFileSync(abs, "utf8");
98
- const parsed = parseFrontMatter(body);
99
- meta = parsed.meta;
100
- content = parsed.content;
101
- } catch (err) {
102
- console.warn(`[WARNING] Failed to parse ${rel}: ${err.message}`);
103
- }
104
-
105
- const title = meta.title || idFromFilename;
106
- const project = meta.project || detectProjectFromBody(content);
107
-
108
- return {
109
- id: meta.id || idFromFilename,
110
- title,
111
- topic: deriveTopicFromBaseName(filename),
112
- group,
113
- fileName: filename,
114
- project,
115
- submodule: meta.submodule || (rel.startsWith(AGENT_ROOT_DIR) ? "" : rel.split("/")[0]),
116
- createdAt: meta.createdAt || statSync(abs).mtime.toISOString(),
117
- updatedAt: statSync(abs).mtime.toISOString(),
118
- source: "ticket-sync",
119
- status: isAlreadyInArchive ? "archived" : (meta.status || "open"),
120
- archiveYearMonth: storage.archiveYearMonth,
121
- };
122
- }
123
-
124
- function parseTicketStorage(rel, isArchived, abs) {
125
- if (!isArchived) {
126
- return { group: basename(dirname(abs)) };
127
- }
128
-
129
- const parts = rel.split("/");
130
- const archiveIdx = parts.indexOf("archive");
131
- const group = parts[archiveIdx + 1] || basename(dirname(abs));
132
- const maybeYearMonth = parts[archiveIdx + 2];
133
- const maybeDay = parts[archiveIdx + 3];
134
-
135
- if (ARCHIVE_YEAR_MONTH_RE.test(String(maybeYearMonth || "")) && ARCHIVE_DAY_RE.test(String(maybeDay || ""))) {
136
- return { group: normalizeTicketGroup(group), archiveYearMonth: maybeYearMonth, archiveDay: maybeDay };
137
- }
138
-
139
- if (ARCHIVE_YEAR_MONTH_RE.test(String(maybeYearMonth || ""))) {
140
- return { group: normalizeTicketGroup(group), archiveYearMonth: maybeYearMonth };
141
- }
142
-
143
- return { group: normalizeTicketGroup(group) };
144
- }
145
-
146
- export function appendTicketEntry(cwd, entry, opts = {}) {
147
- const indexJson = readTicketIndexJson(cwd);
148
- entry.status = entry.status || "open";
149
- // We no longer store 'path' snapshots in INDEX.json
150
- const { path, ...cleanEntry } = entry;
151
- const next = {
152
- version: indexJson.version || 1,
153
- updatedAt: new Date().toISOString(),
154
- activeTicketId: indexJson.activeTicketId,
155
- entries: [cleanEntry, ...indexJson.entries]
156
- };
157
- writeTicketIndexJson(cwd, next, opts);
158
- }
159
-
160
- export function updateTicketEntryStatus(cwd, opts = {}) {
161
- const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts);
162
- let foundIndex = -1;
163
- const targetTopic = opts.topic ? String(opts.topic).toLowerCase() : null;
164
-
165
- if (opts.latest) {
166
- foundIndex = 0;
167
- } else if (targetTopic) {
168
- // Match against both topic AND id for consistency with pickTicketEntry
169
- foundIndex = indexJson.entries.findIndex(e =>
170
- String(e.topic || "").toLowerCase().includes(targetTopic) ||
171
- String(e.id || "").toLowerCase().includes(targetTopic)
172
- );
173
- }
174
-
175
- if (foundIndex === -1) {
176
- throw new Error("No matching ticket found to update status");
177
- }
178
-
179
- const entry = indexJson.entries[foundIndex];
180
- const newStatus = opts.status || "closed";
181
- entry.status = newStatus;
182
- if (newStatus === "closed" || newStatus === "cancelled" || newStatus === "wontfix") {
183
- entry.phase = 4;
184
- }
185
- entry.updatedAt = new Date().toISOString();
186
-
187
- // Sync status back to .md frontmatter to prevent rebuild reversion
188
- const absPath = join(cwd, entry.path);
189
- if (existsSync(absPath)) {
190
- try {
191
- const body = readFileSync(absPath, "utf8");
192
- const parsed = parseFrontMatter(body);
193
- if (parsed.meta.status !== newStatus) {
194
- parsed.meta.status = newStatus;
195
- if (newStatus === "closed" || newStatus === "cancelled" || newStatus === "wontfix") {
196
- parsed.meta.phase = 4;
197
- }
198
- const newBody = stringifyFrontMatter(parsed.meta, parsed.content);
199
- writeFileSync(absPath, newBody, "utf8");
200
- }
201
- } catch (err) {
202
- console.warn(`[WARNING] Failed to sync status to ${entry.path}: ${err.message}`);
203
- }
204
- }
205
-
206
- const next = { version: indexJson.version, updatedAt: new Date().toISOString(), entries: indexJson.entries };
207
- writeTicketIndexJson(cwd, next, opts);
208
- return entry;
209
- }