deuk-agent-flow 5.0.2 → 5.0.7

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 (102) hide show
  1. package/CHANGELOG.ko.md +45 -0
  2. package/CHANGELOG.md +48 -0
  3. package/bundled/deuk-agent-flow.vsix +0 -0
  4. package/core-rules/AGENTS.md +5 -5
  5. package/package.json +20 -2
  6. package/scripts/bundle-vscode-vsix.ts +1 -1
  7. package/scripts/cli-args.ts +46 -1
  8. package/scripts/cli-context-commands.ts +35 -0
  9. package/scripts/cli-init-claude.ts +179 -0
  10. package/scripts/cli-init-commands.ts +21 -8
  11. package/scripts/cli-init-core.ts +89 -0
  12. package/scripts/cli-init-instructions.ts +489 -0
  13. package/scripts/cli-init-migrate.ts +504 -0
  14. package/scripts/cli-init-workspace.ts +63 -0
  15. package/scripts/cli-locales.ts +85 -0
  16. package/scripts/cli-skill-commands.ts +3 -1
  17. package/scripts/cli-ticket-commands.ts +68 -2
  18. package/scripts/cli-ticket-docmeta.ts +103 -0
  19. package/scripts/cli-ticket-document.ts +153 -0
  20. package/scripts/cli-ticket-evidence.ts +312 -0
  21. package/scripts/cli-ticket-home.ts +449 -0
  22. package/scripts/cli-ticket-index.ts +8 -0
  23. package/scripts/cli-ticket-scan.ts +102 -0
  24. package/scripts/cli-ticket-surface.ts +56 -0
  25. package/scripts/cli.ts +10 -1
  26. package/scripts/oss-prepack.js +32 -0
  27. package/scripts/out/scripts/bundle-vscode-vsix.js +68 -0
  28. package/scripts/out/scripts/bundle-vscode-vsix.js.map +1 -0
  29. package/scripts/out/scripts/cli-args.js +411 -0
  30. package/scripts/out/scripts/cli-args.js.map +1 -0
  31. package/scripts/out/scripts/cli-context-commands.js +31 -0
  32. package/scripts/out/scripts/cli-context-commands.js.map +1 -0
  33. package/scripts/out/scripts/cli-init-claude.js +176 -0
  34. package/scripts/out/scripts/cli-init-claude.js.map +1 -0
  35. package/scripts/out/scripts/cli-init-commands.js +98 -0
  36. package/scripts/out/scripts/cli-init-commands.js.map +1 -0
  37. package/scripts/out/scripts/cli-init-core.js +89 -0
  38. package/scripts/out/scripts/cli-init-core.js.map +1 -0
  39. package/scripts/out/scripts/cli-init-instructions.js +460 -0
  40. package/scripts/out/scripts/cli-init-instructions.js.map +1 -0
  41. package/scripts/out/scripts/cli-init-logic.js +41 -0
  42. package/scripts/out/scripts/cli-init-logic.js.map +1 -0
  43. package/scripts/out/scripts/cli-init-migrate.js +497 -0
  44. package/scripts/out/scripts/cli-init-migrate.js.map +1 -0
  45. package/scripts/out/scripts/cli-init-workspace.js +57 -0
  46. package/scripts/out/scripts/cli-init-workspace.js.map +1 -0
  47. package/scripts/out/scripts/cli-locales.js +83 -0
  48. package/scripts/out/scripts/cli-locales.js.map +1 -0
  49. package/scripts/out/scripts/cli-prompts.js +101 -0
  50. package/scripts/out/scripts/cli-prompts.js.map +1 -0
  51. package/scripts/out/scripts/cli-rule-compiler.js +121 -0
  52. package/scripts/out/scripts/cli-rule-compiler.js.map +1 -0
  53. package/scripts/out/scripts/cli-skill-commands.js +708 -0
  54. package/scripts/out/scripts/cli-skill-commands.js.map +1 -0
  55. package/scripts/out/scripts/cli-telemetry-commands.js +604 -0
  56. package/scripts/out/scripts/cli-telemetry-commands.js.map +1 -0
  57. package/scripts/out/scripts/cli-ticket-command-shared.js +2 -0
  58. package/scripts/out/scripts/cli-ticket-command-shared.js.map +1 -0
  59. package/scripts/out/scripts/cli-ticket-commands.js +3538 -0
  60. package/scripts/out/scripts/cli-ticket-commands.js.map +1 -0
  61. package/scripts/out/scripts/cli-ticket-docmeta.js +90 -0
  62. package/scripts/out/scripts/cli-ticket-docmeta.js.map +1 -0
  63. package/scripts/out/scripts/cli-ticket-document.js +121 -0
  64. package/scripts/out/scripts/cli-ticket-document.js.map +1 -0
  65. package/scripts/out/scripts/cli-ticket-evidence.js +286 -0
  66. package/scripts/out/scripts/cli-ticket-evidence.js.map +1 -0
  67. package/scripts/out/scripts/cli-ticket-home.js +440 -0
  68. package/scripts/out/scripts/cli-ticket-home.js.map +1 -0
  69. package/scripts/out/scripts/cli-ticket-index.js +280 -0
  70. package/scripts/out/scripts/cli-ticket-index.js.map +1 -0
  71. package/scripts/out/scripts/cli-ticket-migration.js +261 -0
  72. package/scripts/out/scripts/cli-ticket-migration.js.map +1 -0
  73. package/scripts/out/scripts/cli-ticket-parser.js +90 -0
  74. package/scripts/out/scripts/cli-ticket-parser.js.map +1 -0
  75. package/scripts/out/scripts/cli-ticket-scan.js +93 -0
  76. package/scripts/out/scripts/cli-ticket-scan.js.map +1 -0
  77. package/scripts/out/scripts/cli-ticket-surface.js +53 -0
  78. package/scripts/out/scripts/cli-ticket-surface.js.map +1 -0
  79. package/scripts/out/scripts/cli-usage-commands.js +310 -0
  80. package/scripts/out/scripts/cli-usage-commands.js.map +1 -0
  81. package/scripts/out/scripts/cli-utils.js +1544 -0
  82. package/scripts/out/scripts/cli-utils.js.map +1 -0
  83. package/scripts/out/scripts/cli.js +705 -0
  84. package/scripts/out/scripts/cli.js.map +1 -0
  85. package/scripts/out/scripts/lint-md.js +238 -0
  86. package/scripts/out/scripts/lint-md.js.map +1 -0
  87. package/scripts/out/scripts/lint-rules.js +193 -0
  88. package/scripts/out/scripts/lint-rules.js.map +1 -0
  89. package/scripts/out/scripts/merge-logic.js +38 -0
  90. package/scripts/out/scripts/merge-logic.js.map +1 -0
  91. package/scripts/out/scripts/plan-parser.js +52 -0
  92. package/scripts/out/scripts/plan-parser.js.map +1 -0
  93. package/scripts/out/scripts/ticket-machine.js +94 -0
  94. package/scripts/out/scripts/ticket-machine.js.map +1 -0
  95. package/scripts/out/scripts/ticket-workflow.js +498 -0
  96. package/scripts/out/scripts/ticket-workflow.js.map +1 -0
  97. package/scripts/ticket-machine.ts +99 -0
  98. package/scripts/ticket-workflow.ts +544 -0
  99. package/templates/skills/role-closer/SKILL.md +27 -0
  100. package/templates/skills/role-implementer/SKILL.md +30 -0
  101. package/templates/skills/role-planner/SKILL.md +29 -0
  102. package/templates/skills/role-qa-verifier/SKILL.md +30 -0
@@ -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
+ });
@@ -179,6 +179,14 @@ export function writeTicketIndexJson(cwd, indexJson, opts: CliOpts = {}) {
179
179
  return;
180
180
  }
181
181
  const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
182
+ // #776: dir이 null이면(워크스페이스 마커 미존재 등) 조용히 mkdirSync(null)로 throw하던
183
+ // 것을 막는다. 정상 흐름에선 init이 마커를 먼저 민팅하므로 여기 도달 시 dir은 non-null.
184
+ // 그래도 도달하면 ticket index는 .md에서 재도출 가능한 얇은 포인터일 뿐이라, 명확히
185
+ // 알리고 skip한다(치명적 throw로 상위 init 전체를 위장 실패시키지 않는다).
186
+ if (!dir) {
187
+ console.warn(`[TICKET-INDEX] skipped: no ticket dir resolved for ${cwd} (workspace marker missing?)`);
188
+ return;
189
+ }
182
190
  const p = makePath(dir, TICKET_INDEX_FILENAME);
183
191
  if (opts.dryRun) return;
184
192
  mkdirSync(dir, { recursive: true });
@@ -0,0 +1,102 @@
1
+ // Read-only ticket scanning: derive ticket entries from the .md files that are
2
+ // the single source of truth. This module performs NO writes and depends only on
3
+ // cli-utils + ticket-workflow, so it can be imported by both cli-ticket-index.mjs
4
+ // and cli-ticket-parser.mjs without creating an import cycle.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
6
+ import { basename, dirname } from "path";
7
+ import {
8
+ DEUK_ROOT_DIR, TICKET_LIST_FILENAME,
9
+ toPosixPath, toRepoRelativePath, detectProjectFromBody, normalizeTicketGroup,
10
+ parseFrontMatter, detectConsumerTicketDir,
11
+ ARCHIVE_YEAR_MONTH_RE, ARCHIVE_DAY_RE, makePath
12
+ , CliOpts } from "./cli-utils.js";
13
+ import { inferTicketWorkflowStatus } from "./ticket-workflow.js";
14
+
15
+ export function collectTicketMarkdownFiles(dir, out = []) {
16
+ if (!existsSync(dir)) return out;
17
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
18
+ const abs = makePath(dir, ent.name);
19
+ if (ent.name === "node_modules" || ent.name === ".git") continue;
20
+ if (ent.isDirectory()) collectTicketMarkdownFiles(abs, out);
21
+ else if (ent.isFile() && /\.md$/i.test(ent.name)) {
22
+ const base = ent.name;
23
+ if (base === "LATEST.md" || base === TICKET_LIST_FILENAME || base === "ACTIVE_TICKET.md" || base === "project-memory.md" || base === "PROJECT_RULE.md") continue;
24
+ out.push(abs);
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function parseTicketStorage(rel, isArchived, abs) {
31
+ if (!isArchived) {
32
+ return { group: basename(dirname(abs)) };
33
+ }
34
+
35
+ const parts = rel.split("/");
36
+ const archiveIdx = parts.indexOf("archive");
37
+ const group = parts[archiveIdx + 1] || basename(dirname(abs));
38
+ const maybeYearMonth = parts[archiveIdx + 2];
39
+ const maybeDay = parts[archiveIdx + 3];
40
+
41
+ if (ARCHIVE_YEAR_MONTH_RE.test(String(maybeYearMonth || "")) && ARCHIVE_DAY_RE.test(String(maybeDay || ""))) {
42
+ return { group: normalizeTicketGroup(group), archiveYearMonth: maybeYearMonth, archiveDay: maybeDay };
43
+ }
44
+
45
+ if (ARCHIVE_YEAR_MONTH_RE.test(String(maybeYearMonth || ""))) {
46
+ return { group: normalizeTicketGroup(group), archiveYearMonth: maybeYearMonth };
47
+ }
48
+
49
+ return { group: normalizeTicketGroup(group) };
50
+ }
51
+
52
+ // Build a single ticket entry from its .md file. Read-only: always parses the
53
+ // file (no mtime-cache reuse) — the active ticket count is bounded (MAX_OPEN_TICKETS),
54
+ // so a full parse of the live set is negligible.
55
+ export function buildTicketEntry(abs, cwd) {
56
+ const rel = toPosixPath(toRepoRelativePath(cwd, abs));
57
+ const filename = basename(abs);
58
+ const idFromFilename = filename.replace(/\.md$/i, "");
59
+ const isAlreadyInArchive = rel.includes("/archive/");
60
+ const storage = parseTicketStorage(rel, isAlreadyInArchive, abs);
61
+ const group = storage.group;
62
+ const mtimeIso = statSync(abs).mtime.toISOString();
63
+
64
+ let meta: Record<string, any> = {}, content = "";
65
+ try {
66
+ const body = readFileSync(abs, "utf8");
67
+ const parsed = parseFrontMatter(body);
68
+ meta = parsed.meta;
69
+ content = parsed.content;
70
+ } catch (err) {
71
+ console.warn(`[WARNING] Failed to parse ${rel}: ${err.message}`);
72
+ }
73
+
74
+ const title = meta.title || idFromFilename;
75
+ const project = meta.project || detectProjectFromBody(content);
76
+ const phase = Number(meta.phase || 1);
77
+ const status = inferTicketWorkflowStatus(meta, isAlreadyInArchive);
78
+
79
+ return {
80
+ id: meta.id || idFromFilename,
81
+ title,
82
+ group,
83
+ fileName: filename,
84
+ project,
85
+ mainTicket: meta.mainTicket,
86
+ submodule: meta.submodule || (rel.startsWith(DEUK_ROOT_DIR) ? "" : rel.split("/")[0]),
87
+ createdAt: meta.createdAt || mtimeIso,
88
+ updatedAt: mtimeIso,
89
+ source: "ticket-sync",
90
+ phase,
91
+ status,
92
+ archiveYearMonth: storage.archiveYearMonth,
93
+ };
94
+ }
95
+
96
+ // Derive the full ticket entry list from .md files under the official ticket dir.
97
+ export function collectTicketEntriesFromTopicFiles(cwd) {
98
+ const ticketDir = detectConsumerTicketDir(cwd);
99
+ if (!ticketDir) return [];
100
+ const files = collectTicketMarkdownFiles(ticketDir);
101
+ return files.map(abs => buildTicketEntry(abs, cwd));
102
+ }
@@ -0,0 +1,56 @@
1
+ export function renderTicketWorkflowGateFailure({ ticketId = "<ticket-id>", workspace = "", docmeta = {} as Record<string, any>, reasons = [] }: Record<string, any> = {}) {
2
+ const missing = [...new Set([...(reasons || []), ...(docmeta.validation?.errors || [])])];
3
+ const phase1Reasons = docmeta.slot_source_map?.phase1Plan?.errors || [];
4
+ const lines = [
5
+ `[APPROVAL GATE BLOCKED] Ticket ${ticketId} cannot enter execution yet.`,
6
+ "This is a single-pass gate: satisfy every missing slot below, then run the one command shown.",
7
+ "",
8
+ "Missing slots:"
9
+ ];
10
+
11
+ const slotLabels = {
12
+ phase1Plan: "Phase 1 ticket body is complete",
13
+ userApproval: "User approved the reviewed ticket and Phase 2 transition",
14
+ executionEvidence: "Execution evidence is present",
15
+ verificationEvidence: "Verification evidence is present",
16
+ debuggingDecision: "Debugging decision is present",
17
+ completionEvidence: "Completion evidence is present",
18
+ reopenDecision: "Reopen decision is present"
19
+ };
20
+
21
+ for (const slot of Object.keys(docmeta.slots || {})) {
22
+ if (docmeta.slots[slot]) continue;
23
+ lines.push(`- ${slot}: ${slotLabels[slot] || "required evidence is missing"}`);
24
+ }
25
+
26
+ if (phase1Reasons.length > 0) {
27
+ lines.push("");
28
+ lines.push(`Phase 1 body gaps: ${phase1Reasons.join(", ")}`);
29
+ }
30
+
31
+ if (missing.length > 0) {
32
+ lines.push(`Reason codes: ${missing.join(", ")}`);
33
+ }
34
+
35
+ lines.push("");
36
+ // #633: workspace comes ONLY from --workspace (registry); cwd is never used. Pre-announce
37
+ // so the agent always passes --workspace — omitting it errors out.
38
+ lines.push("NOTE: workspace is resolved ONLY from --workspace (registry); cwd is never used.");
39
+ lines.push("Omitting --workspace will error out, so always pass it on every ticket command.");
40
+ lines.push("");
41
+ lines.push("Run ONLY after the user types approval in chat (승인/approved/yes/진행해):");
42
+ lines.push("```bash");
43
+ lines.push(`deuk-agent-flow ticket move --id ${ticketId} --workspace ${workspace || "<workspace-id>"} --phase 2 --approval approved --non-interactive`);
44
+ lines.push("```");
45
+ lines.push("");
46
+ // #641: give the agent the legitimate exit so a blocked gate never tempts it into
47
+ // forging approval or forcing a phase jump. discard only works on unapproved Phase 1 tickets.
48
+ lines.push("Do NOT self-approve (running --approval approved without the user's chat approval),");
49
+ lines.push("and do NOT force a phase jump. If this unapproved Phase 1 ticket should be abandoned, discard it:");
50
+ lines.push("```bash");
51
+ lines.push(`deuk-agent-flow ticket discard --id ${ticketId} --workspace ${workspace || "<workspace-id>"} --non-interactive`);
52
+ lines.push("```");
53
+ lines.push("");
54
+ lines.push(`DocMeta validation: ${docmeta.validation?.status || "UNKNOWN"}`);
55
+ return lines.join("\n");
56
+ }
package/scripts/cli.ts CHANGED
@@ -472,10 +472,19 @@ async function handleInit(opts, saved, sub = "init") {
472
472
  return;
473
473
  }
474
474
 
475
- runWorkspaceMaintenance(opts.cwd, opts.dryRun, pkgRoot, opts);
475
+ const maintenanceResult = runWorkspaceMaintenance(opts.cwd, opts.dryRun, pkgRoot, opts);
476
476
  if (opts.clean && !opts.dryRun) {
477
477
  removeClaudeUserPromptSubmitHook({ homeDir: opts.homeDir });
478
478
  }
479
+ // #776: init이 실패하면 [DONE]을 출력하지 않고 종료코드를 세운다. 과거엔 워크스페이스
480
+ // maintenance가 throw해도 내부 catch가 삼키고 무조건 [DONE]+EXIT0을 찍어, 신규
481
+ // 워크스페이스가 등록 안 됐는데도 "성공"으로 위장됐다(사용자가 ID 손수 생성 등 위험
482
+ // 우회로 빠지는 2차 피해).
483
+ if (maintenanceResult?.initFailed) {
484
+ process.exitCode = 1;
485
+ console.error(`[FAILED] init did not complete for: ${(maintenanceResult.failures || []).map(f => f.workspace).join(", ")}. Workspace not registered. Fix the error above and re-run.`);
486
+ return;
487
+ }
479
488
  if (sub === "init") console.log(formatInitCompletionMessage(opts.cwd, opts.dryRun));
480
489
  }
481
490