deuk-agent-flow 4.2.7 → 5.0.4

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 (170) hide show
  1. package/CHANGELOG.ko.md +277 -0
  2. package/CHANGELOG.md +794 -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 +60 -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-context-commands.ts +35 -0
  24. package/scripts/cli-init-claude.ts +179 -0
  25. package/scripts/cli-init-commands.ts +99 -0
  26. package/scripts/cli-init-core.ts +89 -0
  27. package/scripts/cli-init-instructions.ts +489 -0
  28. package/scripts/cli-init-logic.ts +46 -0
  29. package/scripts/cli-init-migrate.ts +504 -0
  30. package/scripts/cli-init-workspace.ts +63 -0
  31. package/scripts/cli-locales.ts +85 -0
  32. package/scripts/{cli-prompts.mjs → cli-prompts.ts} +19 -34
  33. package/scripts/{cli-rule-compiler.mjs → cli-rule-compiler.ts} +128 -112
  34. package/scripts/cli-skill-commands.ts +709 -0
  35. package/scripts/{cli-telemetry-commands.mjs → cli-telemetry-commands.ts} +25 -22
  36. package/scripts/cli-ticket-command-shared.ts +4 -0
  37. package/scripts/cli-ticket-commands.ts +3781 -0
  38. package/scripts/cli-ticket-docmeta.ts +103 -0
  39. package/scripts/cli-ticket-document.ts +153 -0
  40. package/scripts/cli-ticket-evidence.ts +312 -0
  41. package/scripts/cli-ticket-home.ts +449 -0
  42. package/scripts/cli-ticket-index.ts +283 -0
  43. package/scripts/{cli-ticket-migration.mjs → cli-ticket-migration.ts} +44 -68
  44. package/scripts/cli-ticket-parser.ts +100 -0
  45. package/scripts/cli-ticket-scan.ts +102 -0
  46. package/scripts/cli-ticket-surface.ts +56 -0
  47. package/scripts/{cli-usage-commands.mjs → cli-usage-commands.ts} +325 -326
  48. package/scripts/cli-utils.ts +1560 -0
  49. package/scripts/cli.ts +695 -0
  50. package/scripts/{lint-md.mjs → lint-md.ts} +32 -42
  51. package/scripts/lint-rules.ts +203 -0
  52. package/scripts/{merge-logic.mjs → merge-logic.ts} +44 -44
  53. package/scripts/oss-prepack.js +32 -0
  54. package/scripts/out/scripts/bundle-vscode-vsix.js +68 -0
  55. package/scripts/out/scripts/bundle-vscode-vsix.js.map +1 -0
  56. package/scripts/out/scripts/cli-args.js +374 -0
  57. package/scripts/out/scripts/cli-args.js.map +1 -0
  58. package/scripts/out/scripts/cli-context-commands.js +31 -0
  59. package/scripts/out/scripts/cli-context-commands.js.map +1 -0
  60. package/scripts/out/scripts/cli-init-claude.js +176 -0
  61. package/scripts/out/scripts/cli-init-claude.js.map +1 -0
  62. package/scripts/out/scripts/cli-init-commands.js +87 -0
  63. package/scripts/out/scripts/cli-init-commands.js.map +1 -0
  64. package/scripts/out/scripts/cli-init-core.js +89 -0
  65. package/scripts/out/scripts/cli-init-core.js.map +1 -0
  66. package/scripts/out/scripts/cli-init-instructions.js +460 -0
  67. package/scripts/out/scripts/cli-init-instructions.js.map +1 -0
  68. package/scripts/out/scripts/cli-init-logic.js +41 -0
  69. package/scripts/out/scripts/cli-init-logic.js.map +1 -0
  70. package/scripts/out/scripts/cli-init-migrate.js +497 -0
  71. package/scripts/out/scripts/cli-init-migrate.js.map +1 -0
  72. package/scripts/out/scripts/cli-init-workspace.js +57 -0
  73. package/scripts/out/scripts/cli-init-workspace.js.map +1 -0
  74. package/scripts/out/scripts/cli-locales.js +83 -0
  75. package/scripts/out/scripts/cli-locales.js.map +1 -0
  76. package/scripts/out/scripts/cli-prompts.js +101 -0
  77. package/scripts/out/scripts/cli-prompts.js.map +1 -0
  78. package/scripts/out/scripts/cli-rule-compiler.js +121 -0
  79. package/scripts/out/scripts/cli-rule-compiler.js.map +1 -0
  80. package/scripts/out/scripts/cli-skill-commands.js +708 -0
  81. package/scripts/out/scripts/cli-skill-commands.js.map +1 -0
  82. package/scripts/out/scripts/cli-telemetry-commands.js +604 -0
  83. package/scripts/out/scripts/cli-telemetry-commands.js.map +1 -0
  84. package/scripts/out/scripts/cli-ticket-command-shared.js +2 -0
  85. package/scripts/out/scripts/cli-ticket-command-shared.js.map +1 -0
  86. package/scripts/out/scripts/cli-ticket-commands.js +3530 -0
  87. package/scripts/out/scripts/cli-ticket-commands.js.map +1 -0
  88. package/scripts/out/scripts/cli-ticket-docmeta.js +90 -0
  89. package/scripts/out/scripts/cli-ticket-docmeta.js.map +1 -0
  90. package/scripts/out/scripts/cli-ticket-document.js +121 -0
  91. package/scripts/out/scripts/cli-ticket-document.js.map +1 -0
  92. package/scripts/out/scripts/cli-ticket-evidence.js +286 -0
  93. package/scripts/out/scripts/cli-ticket-evidence.js.map +1 -0
  94. package/scripts/out/scripts/cli-ticket-home.js +440 -0
  95. package/scripts/out/scripts/cli-ticket-home.js.map +1 -0
  96. package/scripts/out/scripts/cli-ticket-index.js +272 -0
  97. package/scripts/out/scripts/cli-ticket-index.js.map +1 -0
  98. package/scripts/out/scripts/cli-ticket-migration.js +261 -0
  99. package/scripts/out/scripts/cli-ticket-migration.js.map +1 -0
  100. package/scripts/out/scripts/cli-ticket-parser.js +90 -0
  101. package/scripts/out/scripts/cli-ticket-parser.js.map +1 -0
  102. package/scripts/out/scripts/cli-ticket-scan.js +93 -0
  103. package/scripts/out/scripts/cli-ticket-scan.js.map +1 -0
  104. package/scripts/out/scripts/cli-ticket-surface.js +53 -0
  105. package/scripts/out/scripts/cli-ticket-surface.js.map +1 -0
  106. package/scripts/out/scripts/cli-usage-commands.js +310 -0
  107. package/scripts/out/scripts/cli-usage-commands.js.map +1 -0
  108. package/scripts/out/scripts/cli-utils.js +1544 -0
  109. package/scripts/out/scripts/cli-utils.js.map +1 -0
  110. package/scripts/out/scripts/cli.js +696 -0
  111. package/scripts/out/scripts/cli.js.map +1 -0
  112. package/scripts/out/scripts/lint-md.js +238 -0
  113. package/scripts/out/scripts/lint-md.js.map +1 -0
  114. package/scripts/out/scripts/lint-rules.js +193 -0
  115. package/scripts/out/scripts/lint-rules.js.map +1 -0
  116. package/scripts/out/scripts/merge-logic.js +38 -0
  117. package/scripts/out/scripts/merge-logic.js.map +1 -0
  118. package/scripts/out/scripts/plan-parser.js +52 -0
  119. package/scripts/out/scripts/plan-parser.js.map +1 -0
  120. package/scripts/out/scripts/ticket-machine.js +94 -0
  121. package/scripts/out/scripts/ticket-machine.js.map +1 -0
  122. package/scripts/out/scripts/ticket-workflow.js +498 -0
  123. package/scripts/out/scripts/ticket-workflow.js.map +1 -0
  124. package/scripts/{plan-parser.mjs → plan-parser.ts} +53 -53
  125. package/scripts/ticket-machine.ts +99 -0
  126. package/scripts/ticket-workflow.ts +544 -0
  127. package/templates/MODULE_RULE_TEMPLATE.md +11 -11
  128. package/templates/PROJECT_RULE.md +46 -47
  129. package/templates/TICKET_TEMPLATE.ko.md +48 -44
  130. package/templates/TICKET_TEMPLATE.md +48 -44
  131. package/templates/project-memory.md +19 -0
  132. package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +25 -25
  133. package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -27
  134. package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -26
  135. package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -30
  136. package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -26
  137. package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -15
  138. package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -34
  139. package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -32
  140. package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -33
  141. package/templates/rules.d/deukcontext-mcp.md +31 -31
  142. package/templates/rules.d/platform-coexistence.md +29 -29
  143. package/templates/skills/context-recall/SKILL.md +3 -1
  144. package/templates/skills/doc-sync/SKILL.md +111 -0
  145. package/templates/skills/generated-file-guard/SKILL.md +3 -1
  146. package/templates/skills/persona-maid/SKILL.md +65 -0
  147. package/templates/skills/project-pilot/SKILL.md +14 -63
  148. package/templates/skills/role-closer/SKILL.md +27 -0
  149. package/templates/skills/role-implementer/SKILL.md +30 -0
  150. package/templates/skills/role-planner/SKILL.md +29 -0
  151. package/templates/skills/role-qa-verifier/SKILL.md +30 -0
  152. package/templates/skills/safe-refactor/SKILL.md +3 -1
  153. package/templates/skills/ticket-status-surface/SKILL.md +17 -0
  154. package/NOTICE.md +0 -19
  155. package/core-rules/GEMINI.md +0 -7
  156. package/docs/npm-publish-guide.ko.md +0 -70
  157. package/scripts/cli-init-commands.mjs +0 -1759
  158. package/scripts/cli-init-logic.mjs +0 -64
  159. package/scripts/cli-skill-commands.mjs +0 -212
  160. package/scripts/cli-ticket-command-shared.mjs +0 -60
  161. package/scripts/cli-ticket-commands.mjs +0 -2474
  162. package/scripts/cli-ticket-index.mjs +0 -322
  163. package/scripts/cli-ticket-parser.mjs +0 -210
  164. package/scripts/cli-utils.mjs +0 -602
  165. package/scripts/cli.mjs +0 -256
  166. package/scripts/lint-rules.mjs +0 -197
  167. package/scripts/publish-dual-npm.mjs +0 -141
  168. package/scripts/smoke-npm-docker.mjs +0 -102
  169. package/scripts/smoke-npm-local.mjs +0 -110
  170. package/scripts/update-download-badge.mjs +0 -103
@@ -0,0 +1,449 @@
1
+ // ─── 티켓 홈 이전 + self-healing 레지스트리 (#622) ──────────────────────────
2
+ //
3
+ // 티켓 데이터(.md/INDEX.json/claim/archive)는 워크스페이스 git 저장소가 아니라
4
+ // ~/.deuk-agent/tickets/{ticketKey}/ 에만 존재한다. 워크스페이스에는 git-ignore되는
5
+ // 불변 ID 마커(.deuk-agent/workspace-id) 1개만 남아 "주소록 열쇠" 역할을 한다.
6
+ //
7
+ // 매 명령 진입 시 reconcileWorkspace()가:
8
+ // 1. 마커(UUID) 읽기 — 없으면 발급·생성
9
+ // 2. 레지스트리 조회 (id → {path, ticketKey, migratedAt})
10
+ // 3. path 불일치(경로 이동/rename) → 자동 갱신(self-heal)
11
+ // 4. 구 위치(.deuk-agent/tickets)에 잔재 있으면 홈으로 흡수 + 삭제 + .gitignore
12
+ // 모두 idempotent — 여러 번 실행해도 안전하고 데이터 손실 0.
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, rmSync, statSync, appendFileSync, copyFileSync } from "fs";
15
+ import { randomUUID, createHash } from "crypto";
16
+ import { LEGACY_DEUK, DEUK_ROOT_DIR, CliOpts, TICKET_SUBDIR, WORKSPACE_MARKER_FILE, basenameAnyOS, hasWorkspaceMarker, isHomeDirectChild, makePath, normalizeRegistryPath, registerTicketHomeModule, resolveUserHome } from "./cli-utils.js";
17
+ import { collectTicketMarkdownFiles } from "./cli-ticket-scan.js";
18
+
19
+ const WORKSPACE_ID_FILE = "workspace-id";
20
+
21
+ // ─── 홈 경로 ────────────────────────────────────────────────────────────────
22
+
23
+ export function homeTicketRoot(opts: CliOpts = {}) {
24
+ return makePath([resolveUserHome(opts), DEUK_ROOT_DIR, "tickets"], opts);
25
+ }
26
+
27
+ export function resolveHomeTicketDir(ticketKey, opts: CliOpts = {}) {
28
+ return makePath(homeTicketRoot(opts), ticketKey);
29
+ }
30
+
31
+ // ─── 불변 ID 마커 ───────────────────────────────────────────────────────────
32
+
33
+ function workspaceIdMarkerPath(workspaceRoot) {
34
+ // New: .deuk-workspace-id file at workspace root. Fall back to legacy path for
35
+ // read-only callers (readWorkspaceId) so old workspaces still resolve until migration.
36
+ // #736: normalizeRegistryPath converts backslash Windows paths (D:\workspace\X) to
37
+ // a consistent form so makePath does not mangle escape sequences (\w, \D etc.)
38
+ // when running under POSIX node (Git Bash / WSL).
39
+ return makePath(normalizeRegistryPath(workspaceRoot), WORKSPACE_MARKER_FILE);
40
+ }
41
+
42
+ function legacyWorkspaceIdMarkerPath(workspaceRoot) {
43
+ return makePath(normalizeRegistryPath(workspaceRoot), LEGACY_DEUK.rootDir, WORKSPACE_ID_FILE);
44
+ }
45
+
46
+ // Reads the workspace's immutable id WITHOUT creating one (pure read). Returns null
47
+ // if no marker exists yet (workspace not migrated).
48
+ // Checks new .deuk-workspace-id first, then falls back to legacy .deuk-agent/workspace-id.
49
+ export function readWorkspaceId(workspaceRoot) {
50
+ for (const markerPath of [workspaceIdMarkerPath(workspaceRoot), legacyWorkspaceIdMarkerPath(workspaceRoot)]) {
51
+ try {
52
+ const existing = String(readFileSync(markerPath, "utf8") || "").trim();
53
+ if (existing) return existing;
54
+ } catch { /* try next */ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ // Reads the workspace's immutable id. The id survives path moves and renames, so
60
+ // it — not the path — is the stable key for tickets.
61
+ //
62
+ // #645: minting a NEW id (and thus a marker) is gated behind opts.allowCreate.
63
+ // Only `deuk-agent-flow init` passes it. Every other entry point (ticket commands
64
+ // running in some cwd) reads an EXISTING marker or gets null — it must never mint
65
+ // a workspace just because a command ran in a directory. Auto-registration on cwd
66
+ // is exactly what spawned the ghost workspaces; the only way in is an explicit init.
67
+ export function readOrCreateWorkspaceId(workspaceRoot, opts: CliOpts = {}) {
68
+ // Never materialize anything under a path that doesn't exist (a stale-cookie
69
+ // JOIN can fabricate <parentWS>\<childName>).
70
+ if (!existsSync(workspaceRoot)) return null;
71
+ // readWorkspaceId checks new .deuk-workspace-id then legacy .deuk-agent/workspace-id.
72
+ const existing = readWorkspaceId(workspaceRoot);
73
+ if (existing) return existing;
74
+ // No marker yet: this workspace was never initialized. Mint one ONLY when an
75
+ // explicit init asked for it; otherwise return null so callers skip cleanly.
76
+ if (!opts.allowCreate) return null;
77
+ const id = randomUUID();
78
+ if (!opts.dryRun) {
79
+ writeFileSync(workspaceIdMarkerPath(workspaceRoot), id + "\n", "utf8");
80
+ }
81
+ return id;
82
+ }
83
+
84
+ // ─── ticketKey: basename 우선, 동명 충돌만 id suffix ──────────────────────────
85
+
86
+ function shortIdSuffix(id) {
87
+ return createHash("sha256").update(String(id), "utf8").digest("hex").slice(0, 8);
88
+ }
89
+
90
+ // Picks a folder name under the home tickets root. Prefers the bare basename for
91
+ // readability; only when another workspace already owns that name (different id)
92
+ // does it disambiguate with an id-derived suffix.
93
+ export function computeTicketKey(workspaceId, workspaceRoot, registry, opts: CliOpts = {}) {
94
+ // #632: normalizeRegistryPath returns a Windows-style path (e.g. D:\workspace\DeukPack)
95
+ // even when this code runs under a POSIX node (WSL / Git Bash), where path.basename
96
+ // does NOT treat "\\" as a separator — basename("D:\\workspace\\DeukPack") wrongly
97
+ // yields "workspaceDeukPack". That bogus key spawns ~/.deuk-agent/tickets/workspaceDeukPack/
98
+ // and, because the derived id never matches the registry, makes reconcile fire on every
99
+ // command. Split on BOTH separators so the last real path segment is taken.
100
+ const base = basenameAnyOS(normalizeRegistryPath(workspaceRoot)) || "workspace";
101
+ const owner = registry.byKey?.[base];
102
+ if (!owner || owner === workspaceId) return base;
103
+ return `${base}-${shortIdSuffix(workspaceId)}`;
104
+ }
105
+
106
+ // ─── .gitignore 보강 ─────────────────────────────────────────────────────────
107
+
108
+ function ensureGitignored(workspaceRoot, opts: CliOpts = {}) {
109
+ if (opts.dryRun) return;
110
+ // .deuk-workspace-id is committed (not ignored). Remove any stale .deuk-agent/ entry.
111
+ const giPath = makePath(workspaceRoot, ".gitignore");
112
+ if (!existsSync(giPath)) return;
113
+ const gi = readFileSync(giPath, "utf8");
114
+ const legacyLine = `${LEGACY_DEUK.rootDir}/`;
115
+ const cleaned = gi.split(/\r?\n/).filter(l => l.trim() !== legacyLine && l.trim() !== LEGACY_DEUK.rootDir).join("\n");
116
+ if (cleaned !== gi) {
117
+ writeFileSync(giPath, cleaned.replace(/\n*$/, "\n"), "utf8");
118
+ }
119
+ }
120
+
121
+ // ─── 잔재 흡수: 구 위치 tickets → 홈 ─────────────────────────────────────────
122
+
123
+ // rename fails across drives (Windows D:\ → C:\ raises EXDEV). Fall back to
124
+ // copy+unlink so workspaces on a different volume than home still migrate.
125
+ function moveFile(src, dest) {
126
+ try {
127
+ renameSync(src, dest);
128
+ } catch (err) {
129
+ if (err && err.code === "EXDEV") {
130
+ copyFileSync(src, dest);
131
+ rmSync(src, { force: true });
132
+ } else {
133
+ throw err;
134
+ }
135
+ }
136
+ }
137
+
138
+ function moveTreeInto(srcDir, destDir) {
139
+ mkdirSync(destDir, { recursive: true });
140
+ for (const ent of readdirSync(srcDir, { withFileTypes: true })) {
141
+ const src = makePath(srcDir, ent.name);
142
+ const dest = makePath(destDir, ent.name);
143
+ if (ent.isDirectory()) {
144
+ moveTreeInto(src, dest);
145
+ } else {
146
+ // Don't clobber a home file that already exists (home is source of truth).
147
+ if (!existsSync(dest)) moveFile(src, dest);
148
+ else rmSync(src, { force: true });
149
+ }
150
+ }
151
+ rmSync(srcDir, { recursive: true, force: true });
152
+ }
153
+
154
+ // #727: the workspace must end with ONLY the .deuk-workspace-id file — no .deuk or
155
+ // .deuk-agent directory may survive. The two names are handled DIFFERENTLY:
156
+ // - .deuk-agent (LEGACY_DEUK.rootDir): a legitimate former ticket location. Its
157
+ // tickets/ subtree is absorbed into the home (uuid) store first (no data loss),
158
+ // then the whole dir is removed.
159
+ // - .deuk (DEUK_ROOT_DIR): must NEVER exist in a workspace; any occurrence is a
160
+ // BUG artifact (report/knowledge/defrag wrote here by mistake). Delete it whole
161
+ // — there is no real data to preserve (언니 지시).
162
+ // `homeDir` is the only out-of-workspace write target; the workspace itself is only
163
+ // ever DELETED here (migration sync) — never written with new content.
164
+ function absorbWorkspaceResidue(workspaceRoot, homeDir, opts: CliOpts = {}) {
165
+ let absorbed = false;
166
+
167
+ // Legacy .deuk-agent: preserve tickets into home, then remove the dir.
168
+ const legacyDir = makePath(workspaceRoot, LEGACY_DEUK.rootDir);
169
+ try {
170
+ if (existsSync(legacyDir) && statSync(legacyDir).isDirectory()) {
171
+ absorbed = true;
172
+ if (!opts.dryRun) {
173
+ const legacyTickets = makePath(legacyDir, TICKET_SUBDIR);
174
+ try {
175
+ if (existsSync(legacyTickets) && statSync(legacyTickets).isDirectory()) {
176
+ moveTreeInto(legacyTickets, homeDir);
177
+ }
178
+ } catch { /* tolerate; dir removed next regardless */ }
179
+ rmSync(legacyDir, { recursive: true, force: true });
180
+ }
181
+ }
182
+ } catch { /* not a dir / inaccessible — skip */ }
183
+
184
+ // Bug-produced .deuk: delete whole, no preservation.
185
+ const bugDir = makePath(workspaceRoot, DEUK_ROOT_DIR);
186
+ try {
187
+ if (existsSync(bugDir) && statSync(bugDir).isDirectory()) {
188
+ absorbed = true;
189
+ if (!opts.dryRun) rmSync(bugDir, { recursive: true, force: true });
190
+ }
191
+ } catch { /* not a dir / inaccessible — skip */ }
192
+
193
+ return absorbed;
194
+ }
195
+
196
+ // The user home itself (~/.deuk-agent) is the ticket STORE, not a workspace. If a
197
+ // command runs with cwd at/above home, detect must NOT treat home as a workspace —
198
+ // reconciling it would try to absorb the home tickets into themselves (infinite
199
+ // recursion). Returns true when workspaceRoot is the home dir or an ancestor of the
200
+ // home ticket store.
201
+ export function isHomeStoreRoot(workspaceRoot, opts: CliOpts = {}) {
202
+ const home = normalizeRegistryPath(resolveUserHome(opts));
203
+ const root = normalizeRegistryPath(workspaceRoot);
204
+ if (root === home) return true;
205
+ // home tickets live under <home>/.deuk-agent/tickets — guard that whole subtree.
206
+ const store = normalizeRegistryPath(makePath([resolveUserHome(opts), DEUK_ROOT_DIR], opts));
207
+ return root === store || store.startsWith(root + "/") || root.startsWith(store + "/");
208
+ }
209
+
210
+ // ─── uuid 스토어: workspace.json + wp-{name} 더미 (#638) ──────────────────────
211
+ //
212
+ // 홈 스토어 폴더명을 불변 UUID로 쓰면 사람이 폴더를 못 읽는다. 두 가지 단서를 둔다:
213
+ // - workspace.json: { uuid, path, name, aliases } — 기계가 읽는 권위 메타데이터
214
+ // - wp-{name} : 빈 더미 파일 — `ls`만 해도 사람이 워크스페이스를 식별
215
+ // 둘 다 reconcile 시 멱등하게 갱신한다(이름/경로 변경에도 폴더 rename 불필요).
216
+ const WORKSPACE_META_FILE = "workspace.json";
217
+
218
+ function readWorkspaceMeta(homeDir) {
219
+ try {
220
+ return JSON.parse(readFileSync(makePath(homeDir, WORKSPACE_META_FILE), "utf8"));
221
+ } catch { return null; }
222
+ }
223
+
224
+ function writeWorkspaceStoreMarkers(homeDir, { uuid, path, name }, opts: CliOpts = {}) {
225
+ if (opts.dryRun) return;
226
+ mkdirSync(homeDir, { recursive: true });
227
+ const meta = { uuid, path, name, updatedAt: new Date().toISOString() };
228
+ writeFileSync(makePath(homeDir, WORKSPACE_META_FILE), JSON.stringify(meta, null, 2) + "\n", "utf8");
229
+ // Refresh the human-readable wp-{name} dummy: remove stale wp-* then write the
230
+ // current one, so a rename leaves exactly one correct label.
231
+ for (const ent of readdirSync(homeDir, { withFileTypes: true })) {
232
+ if (ent.isFile() && ent.name.startsWith("wp-") && ent.name !== `wp-${name}`) {
233
+ rmSync(makePath(homeDir, ent.name), { force: true });
234
+ }
235
+ }
236
+ if (name) writeFileSync(makePath(homeDir, `wp-${name}`), "", "utf8");
237
+ }
238
+
239
+ // One-time, idempotent relocation of a legacy name-keyed folder to the uuid folder.
240
+ // If the uuid folder already exists, migration is already done → no-op. Otherwise,
241
+ // if the legacy {ticketKey}/ folder exists, move its whole tree under {uuid}/.
242
+ function migrateLegacyKeyFolderToUuid(uuid, ticketKey, opts: CliOpts = {}) {
243
+ const uuidDir = resolveHomeTicketDir(uuid, opts);
244
+ if (existsSync(uuidDir)) return uuidDir; // already on uuid layout
245
+ const legacyDir = resolveHomeTicketDir(ticketKey, opts);
246
+ if (ticketKey && ticketKey !== uuid && existsSync(legacyDir) && !opts.dryRun) {
247
+ moveTreeInto(legacyDir, uuidDir); // {ticketKey}/* → {uuid}/*
248
+ }
249
+ return uuidDir;
250
+ }
251
+
252
+ // ─── reconcile: 매 명령 진입 훅 ──────────────────────────────────────────────
253
+ //
254
+ // 마커 → 레지스트리 → 보정 → 잔재 흡수. 홈 티켓 디렉토리 절대경로를 반환한다.
255
+ export function reconcileWorkspace(workspaceRoot, opts: CliOpts = {}) {
256
+ // Never treat the home ticket store as a workspace (would recurse / pollute the
257
+ // registry with a "home" entry). Callers should guard too, but enforce here.
258
+ if (isHomeStoreRoot(workspaceRoot, opts)) {
259
+ return {
260
+ id: null, ticketKey: null,
261
+ homeDir: homeTicketRoot(opts),
262
+ pathChanged: false, absorbed: false, skipped: true,
263
+ };
264
+ }
265
+ const id = readOrCreateWorkspaceId(workspaceRoot, opts);
266
+ // #645: readOrCreateWorkspaceId returns null when workspaceRoot does not exist
267
+ // (a fabricated ghost path). Bail out before touching the registry so we never
268
+ // register a phantom workspace under a non-existent path.
269
+ if (id == null) {
270
+ return {
271
+ id: null, ticketKey: null,
272
+ homeDir: homeTicketRoot(opts),
273
+ pathChanged: false, absorbed: false, skipped: true,
274
+ };
275
+ }
276
+ const normPath = normalizeRegistryPath(workspaceRoot);
277
+
278
+ // #645: registry.json is retired. The SSOT is the uuid folder's workspace.json.
279
+ // Prefer the name already recorded there; otherwise derive it from the path.
280
+ // computeTicketKey is called with an empty registry — the folder is uuid-keyed,
281
+ // so there is no name collision to disambiguate; it just yields the basename.
282
+ const uuidDir = resolveHomeTicketDir(id, opts);
283
+ const priorMeta = readWorkspaceMeta(uuidDir);
284
+ const name = priorMeta?.name
285
+ || computeTicketKey(id, workspaceRoot, { workspaces: {}, byKey: {} }, opts);
286
+
287
+ // Idempotent: move a legacy {name}/ folder onto {uuid}/ exactly once. After this
288
+ // the uuid folder exists and subsequent runs short-circuit inside the helper.
289
+ const homeDir = migrateLegacyKeyFolderToUuid(id, name, opts);
290
+
291
+ // self-heal: path moved/renamed since workspace.json was last written.
292
+ const pathChanged = !priorMeta || priorMeta.path !== normPath;
293
+
294
+ // Write the in-folder clues (workspace.json + wp-{name}) so the uuid folder is
295
+ // self-describing — this IS the registration record (single source of truth).
296
+ writeWorkspaceStoreMarkers(homeDir, { uuid: id, path: normPath, name }, opts);
297
+
298
+ // Absorb any in-workspace residue (.deuk / .deuk-agent) into the (uuid) home dir
299
+ // and delete the residue dirs, so the workspace keeps only .deuk-workspace-id.
300
+ const absorbed = absorbWorkspaceResidue(workspaceRoot, homeDir, opts);
301
+ if (absorbed) ensureGitignored(workspaceRoot, opts);
302
+
303
+ if (!opts.dryRun) mkdirSync(homeDir, { recursive: true });
304
+ return { id, ticketKey: name, uuid: id, homeDir, pathChanged, absorbed };
305
+ }
306
+
307
+ // PURE READ resolver used by detectConsumerTicketDir (#623). Resolves the home
308
+ // ticket dir for a workspace using only existing state — never creates a marker,
309
+ // moves files, or writes anything. #645: registry.json is retired; the uuid
310
+ // folder's workspace.json is the only record. Resolution order:
311
+ // 1. existing marker id → {uuid}/ folder (the migrated, authoritative location)
312
+ // 2. existing marker id but uuid folder absent → legacy {name}/ folder if present
313
+ // 3. no marker yet (not migrated) → basename (matches what reconcile will pick)
314
+ export function resolveHomeTicketDirForWorkspace(workspaceRoot, opts: CliOpts = {}) {
315
+ const id = readWorkspaceId(workspaceRoot);
316
+ if (id) {
317
+ // #638: uuid-keyed layout. If the {uuid}/ folder exists (migrated), that IS the
318
+ // home dir. Otherwise resolve the legacy {name}/ folder so reads keep working
319
+ // until reconcile relocates it — this read NEVER creates or moves anything.
320
+ const uuidDir = resolveHomeTicketDir(id, opts);
321
+ if (existsSync(uuidDir)) return uuidDir;
322
+ const name = readWorkspaceMeta(uuidDir)?.name
323
+ || computeTicketKey(id, workspaceRoot, { workspaces: {}, byKey: {} }, opts);
324
+ const legacyDir = resolveHomeTicketDir(name, opts);
325
+ return existsSync(legacyDir) ? legacyDir : uuidDir;
326
+ }
327
+ // Not migrated yet (no marker): mirror what reconcile will pick. Pure read.
328
+ // #632: basenameAnyOS — a POSIX runtime would turn D:\workspace\DeukPack into
329
+ // "workspaceDeukPack" with path.basename.
330
+ const name = basenameAnyOS(normalizeRegistryPath(workspaceRoot)) || "workspace";
331
+ return resolveHomeTicketDir(name, opts);
332
+ }
333
+
334
+ // Idempotent migration entry point (#623). Called once on ticket-command entry and
335
+ // on init — NOT from detect. Skips quickly when already migrated (marker present,
336
+ // registry current, no residue). Returns the reconcile result (or a skip marker).
337
+ export function ensureWorkspaceMigrated(workspaceRoot, opts: CliOpts = {}) {
338
+ if (!workspaceRoot) return { skipped: true };
339
+ if (isHomeStoreRoot(workspaceRoot, opts)) return { skipped: true };
340
+ return reconcileWorkspace(workspaceRoot, opts);
341
+ }
342
+
343
+ // ─── ghost 워크스페이스 청소 (#638) ──────────────────────────────────────────
344
+ //
345
+ // 이름 기반 ticketKey 폴더는 동명 워크스페이스 충돌 시 {name}-{idSuffix} 폴더를
346
+ // 양산했고(computeTicketKey), 그 중 다수가 .md 0개인 빈 껍데기로 남았다. 이들은
347
+ // 손으로 rm 하지 않고(메모리 무결성 게이트) 이 함수로 안전하게 제거한다.
348
+ //
349
+ // ghost 판정(보수적, 2조건 모두 충족):
350
+ // 1. 폴더명이 computeTicketKey의 동명 충돌 suffix 패턴 {name}-{8hex} 이고
351
+ // 2. .md 파일이 하나도 없음
352
+ // 즉 "동명 충돌로 생긴 빈 중복 폴더"만 ghost로 본다. suffix 없는 빈 폴더는 티켓이
353
+ // 없을 뿐인 실재 워크스페이스이므로(사용자 지시: 빈 거라도 존재하면 유지) 건드리지
354
+ // 않는다. 데이터가 한 줄이라도 있으면 당연히 제외.
355
+ const GHOST_SUFFIX_RE = /-[0-9a-f]{8}$/;
356
+
357
+ // #645: a path is a nested ghost when it lives strictly UNDER another workspace's
358
+ // path (parent + separator + child). The real workspaces are never prefixes of one
359
+ // another, so any path that is a child of another's path was fabricated by a
360
+ // stale-cookie JOIN / cwd auto-registration — never a real workspace.
361
+ function isNestedUnderAnotherWorkspace(targetPath, allPaths) {
362
+ const target = normalizeRegistryPath(String(targetPath || "")).replace(/[\\/]+$/, "");
363
+ if (!target) return false;
364
+ for (const other of allPaths) {
365
+ const parent = normalizeRegistryPath(String(other || "")).replace(/[\\/]+$/, "");
366
+ if (!parent || parent === target) continue;
367
+ // case-insensitive: Windows paths. target must start with parent + a separator.
368
+ const t = target.toLowerCase();
369
+ const p = parent.toLowerCase();
370
+ if (t.startsWith(p + "\\") || t.startsWith(p + "/")) return true;
371
+ }
372
+ return false;
373
+ }
374
+
375
+ // #645: SSOT is each uuid folder's workspace.json — registry.json is retired. A
376
+ // folder is a ghost when it carries NO ticket data (.md) AND its workspace.json
377
+ // path is not a real, top-level workspace. The classification is two-pass because
378
+ // "nested under another workspace" needs the full set of legitimate paths first.
379
+ function listGhostTicketFolders(opts: CliOpts = {}) {
380
+ const root = homeTicketRoot(opts);
381
+ if (!existsSync(root)) return [];
382
+
383
+ // Pass 1: read every folder's workspace.json + data-bearing flag.
384
+ const entries = [];
385
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
386
+ if (!ent.isDirectory()) continue;
387
+ const dir = makePath(root, ent.name);
388
+ const hasMarkdown = collectTicketMarkdownFiles(dir).length > 0;
389
+ const meta = readWorkspaceMeta(dir);
390
+ const path = meta?.path ? normalizeRegistryPath(String(meta.path)) : "";
391
+ entries.push({ key: ent.name, dir, hasMarkdown, path });
392
+ }
393
+
394
+ // The set of paths that look like genuine top-level workspaces: they exist on
395
+ // disk with a .deuk-agent marker, are not under the home store, and are not a
396
+ // direct child of the home dir. These anchor the nested-ghost predicate.
397
+ const legitPaths = entries
398
+ .map(e => e.path)
399
+ .filter(p => p && hasWorkspaceMarker(p) && !isHomeDirectChild(p, opts));
400
+
401
+ const ghosts = [];
402
+ for (const e of entries) {
403
+ if (e.hasMarkdown) continue; // data-bearing — never a ghost
404
+
405
+ // (a) legacy: collision-suffix folder ({name}-{8hex}) with no workspace.json
406
+ // and no .md — a pre-#638 leftover.
407
+ const suffixGhost = GHOST_SUFFIX_RE.test(e.key) && !e.path;
408
+
409
+ // (b) #645: a folder describing a path that is NOT a real workspace.
410
+ let pathGhost = false;
411
+ if (e.path) {
412
+ const absent = !hasWorkspaceMarker(e.path);
413
+ const homeChild = isHomeDirectChild(e.path, opts);
414
+ const nested = isNestedUnderAnotherWorkspace(e.path, legitPaths);
415
+ pathGhost = absent || homeChild || nested;
416
+ }
417
+
418
+ if (suffixGhost || pathGhost) ghosts.push({ key: e.key, dir: e.dir });
419
+ }
420
+ return ghosts;
421
+ }
422
+
423
+ // Remove empty ghost workspace folders from the home store. Dry-run by default;
424
+ // pass { apply: true } to delete. Returns a summary. Data-bearing folders (any
425
+ // .md) are never touched. #645: registry.json is no longer consulted or written —
426
+ // the uuid folder's workspace.json IS the registration record.
427
+ export function pruneGhostWorkspaces(opts: CliOpts = {}) {
428
+ const apply = Boolean(opts.apply);
429
+ const ghosts = listGhostTicketFolders(opts);
430
+ const removed = [];
431
+ for (const { key, dir } of ghosts) {
432
+ if (apply) rmSync(dir, { recursive: true, force: true });
433
+ removed.push(key);
434
+ }
435
+ return { ghosts: ghosts.map(g => g.key), removed, apply };
436
+ }
437
+
438
+ // Register with cli-utils so detectConsumerTicketDir can route to the home dir
439
+ // without a static circular import.
440
+ registerTicketHomeModule({
441
+ reconcileWorkspace,
442
+ ensureWorkspaceMigrated,
443
+ resolveHomeTicketDirForWorkspace,
444
+ readOrCreateWorkspaceId,
445
+ readWorkspaceId,
446
+ computeTicketKey,
447
+ resolveHomeTicketDir,
448
+ isHomeStoreRoot,
449
+ });