deuk-agent-flow 4.2.7 → 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 (80) hide show
  1. package/CHANGELOG.ko.md +259 -0
  2. package/CHANGELOG.md +769 -124
  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 -120
  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 +42 -45
  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 -25
  47. package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -27
  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 +14 -63
  62. package/templates/skills/safe-refactor/SKILL.md +3 -1
  63. package/templates/skills/ticket-status-surface/SKILL.md +17 -0
  64. package/NOTICE.md +0 -19
  65. package/core-rules/GEMINI.md +0 -7
  66. package/docs/npm-publish-guide.ko.md +0 -70
  67. package/scripts/cli-init-commands.mjs +0 -1759
  68. package/scripts/cli-init-logic.mjs +0 -64
  69. package/scripts/cli-skill-commands.mjs +0 -212
  70. package/scripts/cli-ticket-command-shared.mjs +0 -60
  71. package/scripts/cli-ticket-commands.mjs +0 -2474
  72. package/scripts/cli-ticket-index.mjs +0 -322
  73. package/scripts/cli-ticket-parser.mjs +0 -210
  74. package/scripts/cli-utils.mjs +0 -602
  75. package/scripts/cli.mjs +0 -256
  76. package/scripts/lint-rules.mjs +0 -197
  77. package/scripts/publish-dual-npm.mjs +0 -141
  78. package/scripts/smoke-npm-docker.mjs +0 -102
  79. package/scripts/smoke-npm-local.mjs +0 -110
  80. package/scripts/update-download-badge.mjs +0 -103
@@ -0,0 +1,283 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "fs";
2
+ import { basename, dirname, resolve } from "path";
3
+ import {
4
+ DEUK_ROOT_DIR, TICKET_SUBDIR, TICKET_INDEX_FILENAME,
5
+ requireNonEmptySlug, findFileRecursively, toRepoRelativePath, detectConsumerTicketDir, computeTicketPath, normalizeTicketGroup, writeFileLF, makePath, resolveWorkspaceContext
6
+ , CliOpts } from "./cli-utils.js";
7
+ import { collectTicketEntriesFromTopicFiles } from "./cli-ticket-scan.js";
8
+
9
+ const ARCHIVE_INDEX_MONTH_RE = /^INDEX\.archive\.(\d{4}-\d{2})\.json$/;
10
+ const ARCHIVE_INDEX_LEGACY_RE = /^INDEX\.archive\.json$/;
11
+
12
+ function parseNextTicketSequence(value) {
13
+ const raw = Number(value);
14
+ if (!Number.isInteger(raw) || raw < 1) return null;
15
+ return raw;
16
+ }
17
+
18
+ function maxTicketNumberFromEntries(entries = []) {
19
+ const newRe = /^([0-9]{3,4})-/;
20
+ let max = 0;
21
+ for (const e of (entries || [])) {
22
+ const id = String(e.id || '');
23
+ const m = id.match(newRe);
24
+ if (m) {
25
+ const n = parseInt(m[1], 10);
26
+ if (n > max) max = n;
27
+ }
28
+ }
29
+ return max;
30
+ }
31
+
32
+ function normalizeNextTicketSequence(nextTicketSequence, entries = []) {
33
+ const fallbackNum = maxTicketNumberFromEntries(entries);
34
+ const fallbackNext = fallbackNum < 9999 ? (fallbackNum + 1) : fallbackNum;
35
+ const parsed = parseNextTicketSequence(nextTicketSequence);
36
+ if (parsed === null) return fallbackNext || 1;
37
+ return Math.max(parsed, fallbackNext || 1);
38
+ }
39
+
40
+ function writeJsonFileAtomically(absPath, data) {
41
+ const serialized = JSON.stringify(data, null, 2) + "\n";
42
+ const tmpPath = `${absPath}.tmp-${process.pid}-${Date.now()}`;
43
+ writeFileLF(tmpPath, serialized);
44
+ try {
45
+ renameSync(tmpPath, absPath);
46
+ } catch (err) {
47
+ // POSIX rename(2)은 대상을 원자적으로 덮어쓰지만, Windows renameSync는 대상이
48
+ // 이미 있으면 EEXIST/EPERM으로 throw한다. 그 경우 대상을 먼저 지우고 재시도하고,
49
+ // 그래도 실패하면 직접 덮어쓰기로 폴백한다. 어느 경로든 tmp는 남기지 않는다.
50
+ try {
51
+ if (existsSync(absPath)) {
52
+ unlinkSync(absPath);
53
+ renameSync(tmpPath, absPath);
54
+ } else {
55
+ throw err;
56
+ }
57
+ } catch {
58
+ writeFileLF(absPath, serialized);
59
+ if (existsSync(tmpPath)) {
60
+ try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ function listArchiveIndexFiles(dir) {
67
+ if (!existsSync(dir)) return [];
68
+ return readdirSync(dir, { withFileTypes: true })
69
+ .filter(ent => ent.isFile())
70
+ .map(ent => makePath(dir, ent.name))
71
+ .filter(abs => ARCHIVE_INDEX_LEGACY_RE.test(basename(abs)) || ARCHIVE_INDEX_MONTH_RE.test(basename(abs)))
72
+ .sort((a, b) => {
73
+ const aBase = basename(a);
74
+ const bBase = basename(b);
75
+ const aMatch = aBase.match(ARCHIVE_INDEX_MONTH_RE);
76
+ const bMatch = bBase.match(ARCHIVE_INDEX_MONTH_RE);
77
+ if (aMatch && !bMatch) return 1;
78
+ if (!aMatch && bMatch) return -1;
79
+ if (!aMatch && !bMatch) return aBase.localeCompare(bBase);
80
+ return aMatch[1].localeCompare(bMatch[1]);
81
+ });
82
+ }
83
+
84
+ function parseIndexFile(absPath) {
85
+ if (!existsSync(absPath)) {
86
+ return { version: 1, updatedAt: null, nextTicketSequence: 1, entries: [] };
87
+ }
88
+ try {
89
+ const j = JSON.parse(readFileSync(absPath, "utf8"));
90
+ const entries = Array.isArray(j.entries) ? j.entries.map(e => {
91
+ const entry = { ...e, status: e.status || "open", group: normalizeTicketGroup(e.group, "sub") };
92
+ entry.path = computeTicketPath(entry);
93
+ return entry;
94
+ }) : [];
95
+ // activeTicketId/activeTickets are no longer read from INDEX.json — focus is
96
+ // owned solely by the marker file. Any legacy fields on disk are ignored.
97
+ return {
98
+ version: j.version || 1,
99
+ updatedAt: j.updatedAt ?? null,
100
+ nextTicketSequence: parseNextTicketSequence(j.nextTicketSequence),
101
+ entries
102
+ };
103
+ } catch (err) {
104
+ console.error(`[ERROR] Failed to parse ${basename(absPath)} at ${absPath}:`, err.message);
105
+ return { version: 1, updatedAt: null, nextTicketSequence: 1, entries: [], _corrupt: true };
106
+ }
107
+ }
108
+
109
+ function getWorkspaceKey(cwd) {
110
+ return resolveWorkspaceContext(cwd).breadcrumb;
111
+ }
112
+
113
+ function getWorkspaceSlug(cwd) {
114
+ const key = getWorkspaceKey(cwd);
115
+ return key.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'default';
116
+ }
117
+
118
+ function splitEntriesForStorage(entries = []) {
119
+ const activeEntries = [];
120
+ const archiveEntries = [];
121
+ for (const entry of entries) {
122
+ const status = String(entry?.status || "open").toLowerCase();
123
+ const phase = Number(entry?.phase || 1);
124
+ if ((status === "open" || status === "active") && phase < 4 && !entry?.archiveYearMonth) {
125
+ activeEntries.push(entry);
126
+ } else {
127
+ archiveEntries.push(entry);
128
+ }
129
+ }
130
+ return { activeEntries, archiveEntries };
131
+ }
132
+
133
+
134
+ export function readTicketIndexJson(cwd) {
135
+ const dir = detectConsumerTicketDir(cwd);
136
+ if (!dir) return { version: 1, updatedAt: null, entries: [] };
137
+ const mainPath = makePath(dir, TICKET_INDEX_FILENAME);
138
+ const main = parseIndexFile(mainPath);
139
+
140
+ // The pointer file no longer stores entries — the ticket list is derived from
141
+ // the .md files (single source of truth). Scan them here so every consumer of
142
+ // readTicketIndexJson still sees a populated entry list.
143
+ const entries = collectTicketEntriesFromTopicFiles(cwd).map(entry => {
144
+ const next: Record<string, any> = { ...entry, status: entry.status || "open", group: normalizeTicketGroup(entry.group, "sub") };
145
+ next.path = computeTicketPath(next);
146
+ return next;
147
+ });
148
+
149
+ // #685: LangGraph 모델 — state(현재 노드)는 .md 프론트매터(phase/status)가 유일 SSOT.
150
+ // sessionId/쿠키/claim 폴백 레이어는 전면 제거됨. 현재 노드(activeTicketId)는 디스크의
151
+ // .md 스캔 결과에서 결정적으로 도출한다: live = (open/active) AND phase<4.
152
+ // 여러 개 live면 가장 진행된 노드(phase 높은 것, 동률이면 최신 createdAt)가 현재 노드.
153
+ // 값이 없으면 폴백이 아니라 "현재 노드 없음(null) = START 진입점"이다.
154
+ const liveEntries = entries.filter(e => {
155
+ const status = String(e.status || "open").toLowerCase();
156
+ return (status === "open" || status === "active") && Number(e.phase || 1) < 4 && !e.archiveYearMonth;
157
+ });
158
+ const sortedLive = [...liveEntries].sort((a, b) => {
159
+ const phaseDiff = Number(b.phase || 1) - Number(a.phase || 1);
160
+ if (phaseDiff !== 0) return phaseDiff;
161
+ return String(b.createdAt || "").localeCompare(String(a.createdAt || ""));
162
+ });
163
+ const activeTicketId = sortedLive.length > 0 ? sortedLive[0].id : null;
164
+
165
+ return {
166
+ version: main.version || 1,
167
+ updatedAt: main.updatedAt ?? null,
168
+ activeTicketId,
169
+ liveTicketIds: sortedLive.map(e => e.id),
170
+ nextTicketSequence: normalizeNextTicketSequence(main.nextTicketSequence, entries),
171
+ entries,
172
+ _corrupt: Boolean(main._corrupt)
173
+ };
174
+ }
175
+
176
+ export function writeTicketIndexJson(cwd, indexJson, opts: CliOpts = {}) {
177
+ if (indexJson._corrupt && !opts.force) {
178
+ console.error(`[ABORT] Refusing to overwrite potentially corrupt ${TICKET_INDEX_FILENAME}. Use --force to override.`);
179
+ return;
180
+ }
181
+ const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
182
+ const p = makePath(dir, TICKET_INDEX_FILENAME);
183
+ if (opts.dryRun) return;
184
+ mkdirSync(dir, { recursive: true });
185
+
186
+ // The pointer file is a THIN focus-state file. The ticket list itself is
187
+ // derived from .md files (the single source of truth), so we never persist
188
+ // entries or per-month archive index files. We keep only the two facts that
189
+ // cannot be re-derived from .md alone: the focus pointer (activeTicketId) and
190
+ // the monotonically increasing next ticket number (nextTicketSequence).
191
+ const entries = Array.isArray(indexJson.entries) ? indexJson.entries : [];
192
+
193
+ const currentDiskState = parseIndexFile(p);
194
+
195
+ // nextTicketSequence must never go backwards: max of the stored pointer and
196
+ // (highest known ticket number across ALL entries, active or archived) + 1.
197
+ const callerNextSequence = normalizeNextTicketSequence(indexJson.nextTicketSequence, entries);
198
+ const diskNextSequence = normalizeNextTicketSequence(currentDiskState.nextTicketSequence, entries);
199
+ const nextTicketSequence = Math.max(callerNextSequence, diskNextSequence);
200
+
201
+ const out = {
202
+ version: indexJson.version || 1,
203
+ updatedAt: indexJson.updatedAt || new Date().toISOString(),
204
+ // activeTickets is deliberately omitted to prevent centralized contention
205
+ nextTicketSequence
206
+ };
207
+ writeJsonFileAtomically(p, out);
208
+
209
+ // Remove any legacy per-month archive index files — entries are no longer
210
+ // persisted, so these stale duplicates must not linger.
211
+ for (const filePath of listArchiveIndexFiles(dir)) {
212
+ unlinkSync(filePath);
213
+ }
214
+ }
215
+
216
+ // #675: Claims ticketId for the current session using cookie touch file.
217
+ // Pass ticketId=null to release.
218
+ export function setActiveTicketMarker(cwd, ticketId, opts: CliOpts = {}) {
219
+ // #685: LangGraph 모델 — 현재 노드는 .md phase가 SSOT다. 노드 위치를 쿠키/마커에
220
+ // 별도 저장하지 않는다(폴백 레이어 전면 제거). 이 함수는 하위 호환을 위한 no-op이며,
221
+ // 노드 이동은 ticket move가 .md phase를 갱신하는 것으로만 일어난다.
222
+ return;
223
+ }
224
+
225
+ // #685: getHostnameSlug 제거 — 티켓 ID에서 hostname suffix를 뺐으므로 불필요(언니 지시).
226
+
227
+ // #685: claim/actor/세션 마커 레이어 전면 제거 — LangGraph 모델에서 현재 노드는 .md
228
+ // phase가 SSOT이고 단일 사용자 환경이라 "누가 티켓을 잡았나" 추적이 불필요하다.
229
+ // resolveActor/readClaim/writeClaim/releaseClaim/isClaimStale/migrateLegacyActiveMarkers
230
+ // (전부 sessionId 쿠키 폴백 의존)와 CLAIM_STALE_MS를 삭제했다.
231
+
232
+ export function computeNextTicketNumber(indexState) {
233
+ const maybeEntries = Array.isArray(indexState) ? indexState : indexState?.entries;
234
+ const parsedNext = normalizeNextTicketSequence(indexState?.nextTicketSequence, maybeEntries);
235
+ return { num: parsedNext };
236
+ }
237
+
238
+ export function generateTicketId(titleSlug, indexState) {
239
+ // #685: 티켓 ID의 hostname suffix(-joy-deep 등) 제거 — 단일 환경이라 호스트 구분이
240
+ // 무의미하다(언니 지시). ID는 {num}-{slug}로만 구성한다.
241
+ const slug = requireNonEmptySlug(titleSlug, "ticket title");
242
+ const match = slug.match(/^(\d{3,4})-(.*)/);
243
+ const idSlug = match ? requireNonEmptySlug(match[2], "ticket title") : slug;
244
+ if (/^\d{3,4}$/.test(idSlug)) {
245
+ throw new Error("[VALIDATION FAILED] ticket title must include a non-numeric slug; ticket numbers are assigned by INDEX nextTicketSequence.");
246
+ }
247
+ const { num } = computeNextTicketNumber(indexState);
248
+ const numStr = String(num).padStart(3, '0');
249
+ const finalSlug = idSlug.slice(0, 32);
250
+ return `${numStr}-${finalSlug}`;
251
+ }
252
+
253
+ export function syncActiveTicketId(cwd, opts: CliOpts = {}) {
254
+ // #685: LangGraph 모델 — 현재 노드는 .md phase가 SSOT라 마커 동기화가 불필요하다.
255
+ // 이 함수는 이제 레거시 포인터 파일(LATEST.md/ACTIVE_TICKET.*) 정리만 담당한다.
256
+ const ticketDir = detectConsumerTicketDir(cwd);
257
+ if (!ticketDir) return;
258
+ if (opts.dryRun) return;
259
+
260
+ const legacyLatestPath = makePath(ticketDir, "LATEST.md");
261
+ const pointerPathMd = makePath(ticketDir, "ACTIVE_TICKET.md");
262
+ const pointerPathJson = makePath(ticketDir, "ACTIVE_TICKET.json");
263
+
264
+ for (const p of [legacyLatestPath, pointerPathMd, pointerPathJson]) {
265
+ if (existsSync(p)) {
266
+ unlinkSync(p);
267
+ }
268
+ }
269
+ }
270
+
271
+ export async function syncToPipeline(url, data) {
272
+ try {
273
+ const response = await fetch(url, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify(data),
277
+ signal: AbortSignal?.timeout ? AbortSignal.timeout(3000) : undefined
278
+ });
279
+ return response.ok;
280
+ } catch (err) {
281
+ return false;
282
+ }
283
+ }
@@ -1,11 +1,13 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, statSync } from "fs";
2
- import { basename, dirname, join, relative } from "path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, renameSync } from "fs";
2
+ import { basename, dirname } from "path";
3
3
  import {
4
- toRepoRelativePath, AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_LIST_FILENAME,
5
- parseFrontMatter, stringifyFrontMatter, findFileRecursively, detectConsumerTicketDir
6
- } from "./cli-utils.mjs";
7
- import { readTicketIndexJson, writeTicketIndexJson, syncActiveTicketId } from "./cli-ticket-index.mjs";
8
- import { collectTicketMarkdownFiles, rebuildTicketIndexFromTopicFilesIfNeeded } from "./cli-ticket-parser.mjs";
4
+ toRepoRelativePath, TICKET_SUBDIR, TICKET_LIST_FILENAME,
5
+ parseFrontMatter, findFileRecursively, detectConsumerTicketDir, makePath
6
+ , CliOpts } from "./cli-utils.js";
7
+ import { readTicketIndexJson, syncActiveTicketId, writeTicketIndexJson } from "./cli-ticket-index.js";
8
+ import { collectTicketMarkdownFiles, rebuildTicketIndexFromTopicFilesIfNeeded } from "./cli-ticket-parser.js";
9
+ import { splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
10
+ import { writeTicketDocument } from "./cli-ticket-document.js";
9
11
 
10
12
  // ─── Summary Extraction ─────────────────────────────────────────────────────
11
13
 
@@ -22,7 +24,7 @@ export function extractSummary(meta, content) {
22
24
  const lines = content.split("\n");
23
25
 
24
26
  // Collect section contents by known header patterns
25
- const sections = {};
27
+ const sections: Record<string, any> = {};
26
28
  let currentSection = null;
27
29
  for (const line of lines) {
28
30
  const trimmed = line.trim();
@@ -147,7 +149,7 @@ export function ensureCautionBlock(content) {
147
149
 
148
150
  // ─── Main Migration ─────────────────────────────────────────────────────────
149
151
 
150
- export function performUpgradeMigration(cwd, opts = {}) {
152
+ export function performUpgradeMigration(cwd, opts: CliOpts = {}) {
151
153
  const root = detectConsumerTicketDir(cwd, { createIfMissing: true });
152
154
 
153
155
  const files = collectTicketMarkdownFiles(root).filter(p => {
@@ -191,8 +193,8 @@ export function performUpgradeMigration(cwd, opts = {}) {
191
193
  // 3. Archive placement fix (existing logic)
192
194
  if (meta.status === "archived" && !isAlreadyInArchive && !opts.dryRun) {
193
195
  const finalAbs = moveFileToArchive(cwd, abs, meta.group || basename(dirname(abs)));
194
- const migratedBody = stringifyFrontMatter(meta, modifiedContent);
195
- writeFileSync(finalAbs, migratedBody, "utf8");
196
+ const ticketDocMeta = splitTicketDocMetaSection(modifiedContent);
197
+ writeTicketDocument(finalAbs, { meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
196
198
  upgraded++;
197
199
  console.log(`[OK] Upgraded + archived: ${toRepoRelativePath(cwd, finalAbs)}`);
198
200
  continue;
@@ -205,8 +207,8 @@ export function performUpgradeMigration(cwd, opts = {}) {
205
207
  if (cautionResult !== null) changes.push("caution+targetModule");
206
208
  console.log(`[DRY-RUN] Would upgrade: ${rel} (+${changes.join(", ")})`);
207
209
  } else {
208
- const migratedBody = stringifyFrontMatter(meta, modifiedContent);
209
- writeFileSync(abs, migratedBody, "utf8");
210
+ const ticketDocMeta = splitTicketDocMetaSection(modifiedContent);
211
+ writeTicketDocument(abs, { meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
210
212
  upgraded++;
211
213
  }
212
214
  }
@@ -216,67 +218,26 @@ export function performUpgradeMigration(cwd, opts = {}) {
216
218
 
217
219
  if (!opts.dryRun) {
218
220
  rebuildTicketIndexFromTopicFilesIfNeeded(cwd, { ...opts, force: true });
219
- performDefragmentation(cwd, opts);
220
221
  syncActiveTicketId(cwd);
221
222
  }
222
-
223
+
223
224
  return upgraded;
224
225
  }
225
226
 
226
- // ─── Defragmentation ────────────────────────────────────────────────────────
227
-
228
- export function performDefragmentation(cwd, opts = {}) {
229
- const rootTicketDir = detectConsumerTicketDir(cwd);
230
- if (!rootTicketDir) return;
231
- const tickets = collectTicketMarkdownFiles(rootTicketDir).filter(p => {
232
- const base = basename(p);
233
- return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== "ACTIVE_TICKET.md";
234
- });
235
-
236
- console.log(`[DEFRAG] Checking ${tickets.length} tickets for workspace placement...`);
237
-
238
- const modifiedPaths = new Set();
239
-
240
- for (const abs of tickets) {
241
- const { meta } = parseFrontMatter(readFileSync(abs, "utf8"));
242
- if (meta.submodule && meta.submodule !== "global") {
243
- const subPath = join(cwd, meta.submodule);
244
- if (existsSync(subPath) && statSync(subPath).isDirectory()) {
245
- const subTicketDir = join(subPath, AGENT_ROOT_DIR, TICKET_SUBDIR);
246
-
247
- const relToRoot = relative(rootTicketDir, abs);
248
- const destAbs = join(subTicketDir, relToRoot);
249
-
250
- if (opts.dryRun) {
251
- console.log(`[DRY-RUN] Would move to workspace: ${relToRoot} -> ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/`);
252
- } else {
253
- mkdirSync(dirname(destAbs), { recursive: true });
254
- copyFileSync(abs, destAbs);
255
- unlinkSync(abs);
256
- console.log(`[DEFRAG] Moved: ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/${relToRoot}`);
257
- modifiedPaths.add(subPath);
258
- }
259
- }
260
- }
261
- }
262
-
263
- if (!opts.dryRun) {
264
- for (const p of modifiedPaths) {
265
- rebuildTicketIndexFromTopicFilesIfNeeded(p, { ...opts, force: true });
266
- syncActiveTicketId(p);
267
- }
268
- }
269
- }
227
+ // #727: performDefragmentation 제거 — 홈 uuid 스토어의 티켓을 meta.submodule 기준으로
228
+ // 서브모듈 .deuk/tickets 로 역이동(cwd write)시켜 홈 일원화(#622)를 되돌리던 코드였다.
229
+ // normalizeTicketPaths 주석('티켓은 홈에 산다, cwd 끌어내면 됨') 정면 모순이라
230
+ // 함수와 호출(upgrade 흐름)을 통째로 제거. 티켓은 홈에만 산다.
270
231
 
271
232
  // ─── Utilities ──────────────────────────────────────────────────────────────
272
233
 
273
234
  export function moveFileToArchive(cwd, abs, group) {
274
235
  const ticketDir = detectConsumerTicketDir(cwd);
275
- const archiveBase = join(ticketDir, "archive");
236
+ const archiveBase = makePath(ticketDir, "archive");
276
237
  const targetSubDir = (basename(ticketDir) === TICKET_SUBDIR || !group) ? "sub" : group;
277
- const targetDir = join(archiveBase, targetSubDir);
238
+ const targetDir = makePath(archiveBase, targetSubDir);
278
239
  mkdirSync(targetDir, { recursive: true });
279
- const finalAbs = join(targetDir, basename(abs));
240
+ const finalAbs = makePath(targetDir, basename(abs));
280
241
  if (finalAbs !== abs) {
281
242
  if (existsSync(finalAbs)) {
282
243
  unlinkSync(abs);
@@ -288,7 +249,7 @@ export function moveFileToArchive(cwd, abs, group) {
288
249
  return finalAbs;
289
250
  }
290
251
 
291
- export function normalizeTicketPaths(cwd, opts = {}) {
252
+ export function normalizeTicketPaths(cwd, opts: CliOpts = {}) {
292
253
  const index = readTicketIndexJson(cwd);
293
254
  const ticketDir = detectConsumerTicketDir(cwd);
294
255
  const entries = index.entries || [];
@@ -296,15 +257,30 @@ export function normalizeTicketPaths(cwd, opts = {}) {
296
257
 
297
258
  for (const entry of entries) {
298
259
  if (!entry.path) continue;
299
-
300
- const currentAbs = join(cwd, entry.path);
260
+
261
+ // #622/#623: 티켓 .md는 cwd(워크스페이스 git 트리)가 아니라 홈 ticketDir에 산다.
262
+ // 정상 위치는 makePath(ticketDir, entry.path)이다. entry.path를 cwd 기준으로 해석해
263
+ // 그쪽으로 옮기면, 홈에 멀쩡히 있던 .md를 워크스페이스로 끌어내 홈 디렉토리를 비우고
264
+ // (그 순간 rebuild 스캔이 entries=0을 보고 active claim을 고아로 오인해 삭제한다).
265
+ // 따라서 파일이 이미 ticketDir 하위에 있으면 그게 올바른 위치이므로 이동하지 않는다.
266
+ const canonicalAbs = makePath(ticketDir, entry.path);
267
+ if (existsSync(canonicalAbs)) continue;
268
+
269
+ const currentAbs = makePath(cwd, entry.path);
301
270
  if (!existsSync(currentAbs)) {
302
271
  const fileName = basename(entry.path);
303
272
  const found = findFileRecursively(ticketDir, fileName);
304
273
  if (found) {
305
- const newRel = toRepoRelativePath(cwd, found);
306
- if (entry.path !== newRel) {
307
- entry.path = newRel;
274
+ // 발견된 파일이 이미 홈 ticketDir 하위면 정상 위치 — 워크스페이스로 옮기지 않는다.
275
+ const destAbs = canonicalAbs;
276
+ if (found !== destAbs) {
277
+ mkdirSync(dirname(destAbs), { recursive: true });
278
+ try {
279
+ renameSync(found, destAbs);
280
+ } catch {
281
+ copyFileSync(found, destAbs);
282
+ unlinkSync(found);
283
+ }
308
284
  modified = true;
309
285
  }
310
286
  }
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import {
3
+ parseFrontMatter, discoverAllWorkspaces, detectConsumerTicketDir, computeTicketPath, makePath
4
+ , CliOpts } from "./cli-utils.js";
5
+ import { readTicketIndexJson, writeTicketIndexJson } from "./cli-ticket-index.js";
6
+ import { splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
7
+ import { writeTicketDocument } from "./cli-ticket-document.js";
8
+
9
+ export { collectTicketMarkdownFiles } from "./cli-ticket-scan.js";
10
+
11
+ export function discoverAllTicketDirs(baseCwd, out = []) {
12
+ return discoverAllWorkspaces(baseCwd, undefined, new Set(out));
13
+ }
14
+
15
+ export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts: CliOpts = {}) {
16
+ // readTicketIndexJson already derives entries from the .md files (single source
17
+ // of truth) and merges in the thin pointer state (activeTicketId/nextTicketSequence).
18
+ const indexJson = readTicketIndexJson(cwd);
19
+
20
+ const ticketDir = detectConsumerTicketDir(cwd);
21
+ if (!ticketDir) return indexJson;
22
+
23
+ // Persist the thin pointer file so activeTicketId/nextTicketSequence are kept in
24
+ // sync on disk. writeTicketIndexJson stores ONLY the pointer state, never entries.
25
+ writeTicketIndexJson(cwd, indexJson, indexJson._corrupt ? { ...opts, force: true } : opts);
26
+ if (opts.rebuild) console.log(`[REBUILD] Ticket pointer synced; ${indexJson.entries.length} entries derived from .md.`);
27
+ return indexJson;
28
+ }
29
+
30
+ export function appendTicketEntry(cwd, entry, opts: CliOpts = {}) {
31
+ const indexJson = readTicketIndexJson(cwd);
32
+ entry.status = entry.status || "open";
33
+ // We no longer store 'path' snapshots in INDEX.json
34
+ const { path, ...cleanEntry } = entry;
35
+ const next = {
36
+ version: indexJson.version || 1,
37
+ updatedAt: new Date().toISOString(),
38
+ activeTicketId: indexJson.activeTicketId,
39
+ entries: [cleanEntry, ...indexJson.entries]
40
+ };
41
+ writeTicketIndexJson(cwd, next, opts);
42
+ }
43
+
44
+ export function updateTicketEntryStatus(cwd, opts: CliOpts = {}) {
45
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts);
46
+ let foundIndex = -1;
47
+ const targetId = opts.ticketId ? String(opts.ticketId).toLowerCase() : null;
48
+
49
+ if (opts.latest) {
50
+ foundIndex = 0;
51
+ } else if (targetId) {
52
+ foundIndex = indexJson.entries.findIndex(e =>
53
+ String(e.id || "").toLowerCase().includes(targetId)
54
+ );
55
+ }
56
+
57
+ if (foundIndex === -1) {
58
+ throw new Error("No matching ticket found to update status");
59
+ }
60
+
61
+ const entry = indexJson.entries[foundIndex];
62
+ const newStatus = opts.status || "closed";
63
+ entry.status = newStatus;
64
+ if (newStatus === "open" || newStatus === "active") {
65
+ delete entry.archiveYearMonth;
66
+ delete entry.archiveDay;
67
+ }
68
+ if (newStatus === "closed" || newStatus === "completed" || newStatus === "cancelled" || newStatus === "wontfix") {
69
+ entry.phase = 4;
70
+ }
71
+ entry.updatedAt = new Date().toISOString();
72
+
73
+ // Sync status back to .md frontmatter to prevent rebuild reversion.
74
+ // #736: cwd is the WORKSPACE root; tickets live in the HOME store (detectConsumerTicketDir).
75
+ // makePath(workspaceRoot, relativePath) misses the file → use the ticket dir as base.
76
+ const entryPath = entry.path || computeTicketPath(entry);
77
+ const ticketDir = detectConsumerTicketDir(cwd) || cwd;
78
+ const absPath = makePath(ticketDir, entryPath);
79
+ if (existsSync(absPath)) {
80
+ try {
81
+ const body = readFileSync(absPath, "utf8");
82
+ const parsed = parseFrontMatter(body);
83
+ if (parsed.parseError) throw new Error(parsed.parseError);
84
+ if (parsed.meta.status !== newStatus) {
85
+ parsed.meta.status = newStatus;
86
+ if (newStatus === "closed" || newStatus === "completed" || newStatus === "cancelled" || newStatus === "wontfix") {
87
+ parsed.meta.phase = 4;
88
+ }
89
+ const ticketDocMeta = splitTicketDocMetaSection(parsed.content || "");
90
+ writeTicketDocument(absPath, { meta: parsed.meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
91
+ }
92
+ } catch (err) {
93
+ console.warn(`[WARNING] Failed to sync status to ${entryPath}: ${err.message}`);
94
+ }
95
+ }
96
+
97
+ const next = { version: indexJson.version, updatedAt: new Date().toISOString(), entries: indexJson.entries };
98
+ writeTicketIndexJson(cwd, next, opts);
99
+ return entry;
100
+ }