deuk-agent-flow 5.0.2 → 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 (98) hide show
  1. package/CHANGELOG.ko.md +18 -0
  2. package/CHANGELOG.md +25 -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-context-commands.ts +35 -0
  8. package/scripts/cli-init-claude.ts +179 -0
  9. package/scripts/cli-init-core.ts +89 -0
  10. package/scripts/cli-init-instructions.ts +489 -0
  11. package/scripts/cli-init-migrate.ts +504 -0
  12. package/scripts/cli-init-workspace.ts +63 -0
  13. package/scripts/cli-locales.ts +85 -0
  14. package/scripts/cli-skill-commands.ts +3 -1
  15. package/scripts/cli-ticket-commands.ts +58 -0
  16. package/scripts/cli-ticket-docmeta.ts +103 -0
  17. package/scripts/cli-ticket-document.ts +153 -0
  18. package/scripts/cli-ticket-evidence.ts +312 -0
  19. package/scripts/cli-ticket-home.ts +449 -0
  20. package/scripts/cli-ticket-scan.ts +102 -0
  21. package/scripts/cli-ticket-surface.ts +56 -0
  22. package/scripts/oss-prepack.js +32 -0
  23. package/scripts/out/scripts/bundle-vscode-vsix.js +68 -0
  24. package/scripts/out/scripts/bundle-vscode-vsix.js.map +1 -0
  25. package/scripts/out/scripts/cli-args.js +374 -0
  26. package/scripts/out/scripts/cli-args.js.map +1 -0
  27. package/scripts/out/scripts/cli-context-commands.js +31 -0
  28. package/scripts/out/scripts/cli-context-commands.js.map +1 -0
  29. package/scripts/out/scripts/cli-init-claude.js +176 -0
  30. package/scripts/out/scripts/cli-init-claude.js.map +1 -0
  31. package/scripts/out/scripts/cli-init-commands.js +87 -0
  32. package/scripts/out/scripts/cli-init-commands.js.map +1 -0
  33. package/scripts/out/scripts/cli-init-core.js +89 -0
  34. package/scripts/out/scripts/cli-init-core.js.map +1 -0
  35. package/scripts/out/scripts/cli-init-instructions.js +460 -0
  36. package/scripts/out/scripts/cli-init-instructions.js.map +1 -0
  37. package/scripts/out/scripts/cli-init-logic.js +41 -0
  38. package/scripts/out/scripts/cli-init-logic.js.map +1 -0
  39. package/scripts/out/scripts/cli-init-migrate.js +497 -0
  40. package/scripts/out/scripts/cli-init-migrate.js.map +1 -0
  41. package/scripts/out/scripts/cli-init-workspace.js +57 -0
  42. package/scripts/out/scripts/cli-init-workspace.js.map +1 -0
  43. package/scripts/out/scripts/cli-locales.js +83 -0
  44. package/scripts/out/scripts/cli-locales.js.map +1 -0
  45. package/scripts/out/scripts/cli-prompts.js +101 -0
  46. package/scripts/out/scripts/cli-prompts.js.map +1 -0
  47. package/scripts/out/scripts/cli-rule-compiler.js +121 -0
  48. package/scripts/out/scripts/cli-rule-compiler.js.map +1 -0
  49. package/scripts/out/scripts/cli-skill-commands.js +708 -0
  50. package/scripts/out/scripts/cli-skill-commands.js.map +1 -0
  51. package/scripts/out/scripts/cli-telemetry-commands.js +604 -0
  52. package/scripts/out/scripts/cli-telemetry-commands.js.map +1 -0
  53. package/scripts/out/scripts/cli-ticket-command-shared.js +2 -0
  54. package/scripts/out/scripts/cli-ticket-command-shared.js.map +1 -0
  55. package/scripts/out/scripts/cli-ticket-commands.js +3530 -0
  56. package/scripts/out/scripts/cli-ticket-commands.js.map +1 -0
  57. package/scripts/out/scripts/cli-ticket-docmeta.js +90 -0
  58. package/scripts/out/scripts/cli-ticket-docmeta.js.map +1 -0
  59. package/scripts/out/scripts/cli-ticket-document.js +121 -0
  60. package/scripts/out/scripts/cli-ticket-document.js.map +1 -0
  61. package/scripts/out/scripts/cli-ticket-evidence.js +286 -0
  62. package/scripts/out/scripts/cli-ticket-evidence.js.map +1 -0
  63. package/scripts/out/scripts/cli-ticket-home.js +440 -0
  64. package/scripts/out/scripts/cli-ticket-home.js.map +1 -0
  65. package/scripts/out/scripts/cli-ticket-index.js +272 -0
  66. package/scripts/out/scripts/cli-ticket-index.js.map +1 -0
  67. package/scripts/out/scripts/cli-ticket-migration.js +261 -0
  68. package/scripts/out/scripts/cli-ticket-migration.js.map +1 -0
  69. package/scripts/out/scripts/cli-ticket-parser.js +90 -0
  70. package/scripts/out/scripts/cli-ticket-parser.js.map +1 -0
  71. package/scripts/out/scripts/cli-ticket-scan.js +93 -0
  72. package/scripts/out/scripts/cli-ticket-scan.js.map +1 -0
  73. package/scripts/out/scripts/cli-ticket-surface.js +53 -0
  74. package/scripts/out/scripts/cli-ticket-surface.js.map +1 -0
  75. package/scripts/out/scripts/cli-usage-commands.js +310 -0
  76. package/scripts/out/scripts/cli-usage-commands.js.map +1 -0
  77. package/scripts/out/scripts/cli-utils.js +1544 -0
  78. package/scripts/out/scripts/cli-utils.js.map +1 -0
  79. package/scripts/out/scripts/cli.js +696 -0
  80. package/scripts/out/scripts/cli.js.map +1 -0
  81. package/scripts/out/scripts/lint-md.js +238 -0
  82. package/scripts/out/scripts/lint-md.js.map +1 -0
  83. package/scripts/out/scripts/lint-rules.js +193 -0
  84. package/scripts/out/scripts/lint-rules.js.map +1 -0
  85. package/scripts/out/scripts/merge-logic.js +38 -0
  86. package/scripts/out/scripts/merge-logic.js.map +1 -0
  87. package/scripts/out/scripts/plan-parser.js +52 -0
  88. package/scripts/out/scripts/plan-parser.js.map +1 -0
  89. package/scripts/out/scripts/ticket-machine.js +94 -0
  90. package/scripts/out/scripts/ticket-machine.js.map +1 -0
  91. package/scripts/out/scripts/ticket-workflow.js +498 -0
  92. package/scripts/out/scripts/ticket-workflow.js.map +1 -0
  93. package/scripts/ticket-machine.ts +99 -0
  94. package/scripts/ticket-workflow.ts +544 -0
  95. package/templates/skills/role-closer/SKILL.md +27 -0
  96. package/templates/skills/role-implementer/SKILL.md +30 -0
  97. package/templates/skills/role-planner/SKILL.md +29 -0
  98. package/templates/skills/role-qa-verifier/SKILL.md +30 -0
@@ -0,0 +1,3530 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync, statSync } from "fs";
2
+ import { basename, dirname, join, relative, resolve, sep } from "path";
3
+ import { toSlug, requireNonEmptySlug, toRepoRelativePath, toWsRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, sanitizeFrontMatterMeta, stringifyFrontMatter, stripLeadingFrontMatter, resolveDocsLanguage, inferDocsLanguageFromText, normalizeDocsLanguage, isMcpActive, withReadline, parseFrontMatter, collectMcpConfigStatuses, DEUK_ROOT_DIR, TICKET_SUBDIR, WORKFLOW_MODE_EXECUTE, detectConsumerTicketDir, resolveConsumerTicketRoot, resolveWorkspaceContext, loadInitConfig, computeTicketPath, normalizeTicketGroup, normalizeWorkflowMode, loadWorkspaceCandidates, resolveWorkspaceTarget, findPromptWorkspaceMatches, selectLocalizedTemplatePath, buildPromptWorkspaceAliases, writeFileLF, makePath, resolvePackageRoot, readWorkspaceCookie } from "./cli-utils.js";
4
+ import { buildDocMetaFrontmatterSummary, buildTransitionValidationDocMeta, clearDocMetaFrontmatterSummary, splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
5
+ import { readTicketDocument, resolveTicketEntryOrComputedPath, renderTicketDocument, writeTicketDocument, writeTicketMarkdownFile } from "./cli-ticket-document.js";
6
+ import { generateTicketId, readTicketIndexJson, syncActiveTicketId, syncToPipeline, writeTicketIndexJson } from "./cli-ticket-index.js";
7
+ // Registers the ticket-home module so detectConsumerTicketDir routes ticket storage
8
+ // to ~/.deuk-agent/tickets/ (#622), and exposes home helpers for #623 guards.
9
+ import { isHomeStoreRoot as isHomeStoreRootDir, resolveHomeTicketDirForWorkspace } from "./cli-ticket-home.js";
10
+ import { collectTicketMarkdownFiles } from "./cli-ticket-scan.js";
11
+ import { appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, updateTicketEntryStatus } from "./cli-ticket-parser.js";
12
+ import { appendInternalWorkflowEvent, buildTelemetrySummary, getTelemetryCompactSummary } from "./cli-telemetry-commands.js";
13
+ import { parsePlan } from "./plan-parser.js";
14
+ import { collectChangedFiles, lintMarkdownPaths, normalizeMarkdownFile } from "./lint-md.js";
15
+ import { auditRules } from "./lint-rules.js";
16
+ import { getUsageReminderLine } from "./cli-usage-commands.js";
17
+ import { cliText } from "./cli-locales.js";
18
+ import { extractMarkdownSection, followUpDecisionMeansNoFollowUp, getAnalysisDesignIncompleteReasons, getCloseWorkflowReasons, getPhase1PlanBodyReasons, hasPhase2ExecutionEvidence, hasPlaceholderTokens, hasTicketCompletionEvidence, normalizePhase1PlanBodyHeadings, phase1SectionsForType } from "./cli-ticket-evidence.js";
19
+ import { renderTicketWorkflowGateFailure } from "./cli-ticket-surface.js";
20
+ import { AUTO_ARCHIVE_DONE_STATUSES, OPEN_TICKET_STATUSES, FLOW_SURFACE_STATUS, normalizeTicketPhaseNumber, sanitizeTicketWorkspaceLabel, formatTicketFlowLine, renderTicketFlowSurface, renderTicketDocMetaSurface, resolveTicketWorkflowTransition, getTicketWorkflowTransitionEvidenceKeys, assertTicketWorkflowAction, assertTicketWorkflowTransition, TICKET_WORKFLOW, resolveTicketWorkflowRuntimeState, deriveTicketWorkflowStatus, isTerminalTicketStatus, isExecutionTicketStatus } from "./ticket-workflow.js";
21
+ import { detectCurrentPlatform, getActiveSkillsSurface } from "./cli-skill-commands.js";
22
+ import ejs from "ejs";
23
+ import YAML from "yaml";
24
+ import { selectOne } from "./cli-prompts.js";
25
+ const MAX_OPEN_TICKETS = 20;
26
+ const PACKAGE_ROOT = resolvePackageRoot({ fromUrl: import.meta.url });
27
+ // #633: workspace is resolved ONLY from --workspace (registry); cwd is never used as a
28
+ // fallback. Every surface that prints a ticket CLI command must pre-announce this BEFORE
29
+ // the command so the agent always passes --workspace, otherwise the command errors out.
30
+ const WORKSPACE_PREFLIGHT_NOTICE = [
31
+ "NOTE: workspace is resolved ONLY from --workspace (registry); cwd is never used.",
32
+ "Omitting --workspace will error out, so always pass it on every ticket command below."
33
+ ];
34
+ // Build the lines that pre-announce the --workspace requirement, returning [] when no
35
+ // workspace name can be derived (so the notice never points at an empty --workspace).
36
+ function workspacePreflightLines(cwd) {
37
+ return basename(String(cwd || "")) ? [...WORKSPACE_PREFLIGHT_NOTICE, ""] : [];
38
+ }
39
+ function loadTicketIndex(opts, extra = {}) {
40
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, {
41
+ ...opts,
42
+ force: extra.force ?? false,
43
+ rebuild: extra.rebuild ?? false
44
+ });
45
+ if (extra.autoArchiveDone === false || opts.pathOnly) {
46
+ return index;
47
+ }
48
+ autoArchiveDoneTickets(opts.cwd, index, opts);
49
+ return readTicketIndexJson(opts.cwd);
50
+ }
51
+ async function ensurePhase0Validation(opts) {
52
+ if (!opts.evidence && !opts.skipPhase0) {
53
+ // No more interactive prompts. Default to skip if no evidence provided.
54
+ opts.skipPhase0 = true;
55
+ }
56
+ if (opts.skipPhase0) {
57
+ try {
58
+ if (!isCompactTicketOutput(opts) && await isMcpActive(opts.cwd)) {
59
+ console.warn("[WARNING] Phase 0 RAG evidence is recommended when the MCP server is active. Proceeding without evidence as requested.");
60
+ }
61
+ }
62
+ catch (err) {
63
+ // MCP detection failure should not block ticket creation
64
+ if (process.env.DEBUG)
65
+ console.warn("[DEBUG] MCP activation check failed:", err.message);
66
+ }
67
+ }
68
+ }
69
+ function resolveTicketDocsLanguage(cwd, docsLanguageInput, promptText = "") {
70
+ const config = loadInitConfig(cwd) || {};
71
+ const explicitDocsLanguage = normalizeDocsLanguage(docsLanguageInput);
72
+ const configDocsLanguage = normalizeDocsLanguage(config.docsLanguage || "auto");
73
+ const promptDocsLanguage = explicitDocsLanguage === "auto" && configDocsLanguage === "auto"
74
+ ? inferDocsLanguageFromText(promptText)
75
+ : null;
76
+ const docsLanguage = resolveDocsLanguage(explicitDocsLanguage !== "auto"
77
+ ? explicitDocsLanguage
78
+ : configDocsLanguage !== "auto"
79
+ ? configDocsLanguage
80
+ : promptDocsLanguage || "en");
81
+ return docsLanguage;
82
+ }
83
+ const CLAIM_STOP_WORDS = new Set([
84
+ "the",
85
+ "and",
86
+ "or",
87
+ "is",
88
+ "are",
89
+ "was",
90
+ "were",
91
+ "be",
92
+ "this",
93
+ "that",
94
+ "with",
95
+ "for",
96
+ "from",
97
+ "in",
98
+ "on",
99
+ "of",
100
+ "to",
101
+ "it",
102
+ "ticket",
103
+ "issue",
104
+ "problem",
105
+ "analysis",
106
+ "failed",
107
+ "failure",
108
+ "record",
109
+ "recorded",
110
+ "claim",
111
+ "claiming",
112
+ "result",
113
+ "resulted",
114
+ "caused",
115
+ "causing",
116
+ "this",
117
+ "그",
118
+ "이",
119
+ "이슈",
120
+ "문제",
121
+ "실패",
122
+ "원인",
123
+ "분석",
124
+ "기록",
125
+ "티켓"
126
+ ]);
127
+ function tokenizeClaimText(text) {
128
+ const raw = String(text || "").toLowerCase();
129
+ const tokens = raw.match(/[0-9a-z가-힣_.-]+/g) || [];
130
+ const filtered = tokens
131
+ .filter(t => t.length > 2)
132
+ .filter(t => !CLAIM_STOP_WORDS.has(t));
133
+ return [...new Set(filtered)];
134
+ }
135
+ function collectClaimTargetSections(content) {
136
+ const targetSections = ["Analysis", "Direction", "Problem Analysis", "Source Observations", "Cause Hypotheses", "Improvement Direction"];
137
+ return targetSections.map(name => extractMarkdownSection(content, name)).join(" ");
138
+ }
139
+ function buildClaimCoverageSummary(claimTerms, sectionText) {
140
+ const haystack = String(sectionText || "").toLowerCase();
141
+ const normalized = haystack.replace(/\s+/g, " ");
142
+ const matched = claimTerms.filter(term => normalized.includes(term));
143
+ const missRate = claimTerms.length === 0 ? 0 : 1 - matched.length / claimTerms.length;
144
+ return {
145
+ matched,
146
+ missRate,
147
+ total: claimTerms.length
148
+ };
149
+ }
150
+ // Single source of truth for the fillable Phase 1 plan-body scaffold. Both the
151
+ // human-readable guard message and the --json repair payload surface this exact
152
+ // skeleton so an agent can fill the sections and pass the guard instead of being
153
+ // blocked with headings-only text (the scaffold was previously omitted from the
154
+ // default text path and hand-maintained separately in the JSON path).
155
+ // Per-section placeholder hints used when rendering the fillable scaffold. The set of
156
+ // sections shown is derived from the validator (phase1SectionsForType) so guidance and
157
+ // validation can never drift again (#627).
158
+ const PHASE1_SECTION_HINTS = {
159
+ "Scope & Approval": [
160
+ "범위: <어떤 파일/함수까지>",
161
+ "요구사항: <사용자가 원하는 결과>",
162
+ "접근법: <구체적 방법>"
163
+ ],
164
+ "Plan": ["1. <실행 단계>"],
165
+ "Analysis": [
166
+ "원인: <근본 원인 분석>",
167
+ "- <file:line — 관찰 증거>",
168
+ "가설: 1. <가설>"
169
+ ],
170
+ "Direction": ["<개선 방향>"]
171
+ };
172
+ // Builds the fillable scaffold for a given ticket type, showing only the sections that
173
+ // type actually requires (non-CLI types omit the two CLI sections).
174
+ function buildPhase1PlanBodySkeleton() {
175
+ const lines = [];
176
+ for (const section of phase1SectionsForType()) {
177
+ lines.push(`## ${section}`);
178
+ lines.push(...(PHASE1_SECTION_HINTS[section] || ["<fill>"]));
179
+ lines.push("");
180
+ }
181
+ return lines.join("\n").trimEnd();
182
+ }
183
+ function buildPlanBodyRequiredMessage(reasons = []) {
184
+ const uniqueReasons = [...new Set(reasons)];
185
+ const locale = resolveDocsLanguage("auto");
186
+ const headings = phase1SectionsForType().map(s => `\`## ${s}\``).join(", ");
187
+ return [
188
+ "[VALIDATION FAILED] ticket create requires a filled Phase 1 plan body with actual data.",
189
+ `Missing or incomplete: ${uniqueReasons.join(", ")}`,
190
+ cliText("ticket.validation.selfServeRecipe", { locale }),
191
+ "Use: `deuk-agent-flow ticket create --title <title> --summary \"<summary>\" --plan-body \"<filled body>\" --non-interactive`.",
192
+ `Use these exact H2 headings: ${headings}. (Aliases accepted: "## APC", "## Agent Permission Contract", "## Scope & Approval", "## Compact Plan", "## Problem Analysis", "## Source Observations", "## Audit Evidence", "## Improvement Direction" — all normalized before validation.)`,
193
+ "Do not rely on template defaults or auto-generated filler text for Phase 1 ticket content.",
194
+ "",
195
+ "Fillable plan-body scaffold (fill every section with real evidence, then pass via --plan-body):",
196
+ buildPhase1PlanBodySkeleton()
197
+ ].filter(Boolean).join("\n");
198
+ }
199
+ function extractMarkdownHeadingSection(content = "", heading = "") {
200
+ const target = String(heading || "").trim().replace(/^#+\s*/, "").toLowerCase();
201
+ if (!target)
202
+ return "";
203
+ const lines = String(content || "").split(/\r?\n/);
204
+ let start = -1;
205
+ let level = 0;
206
+ for (const [index, line] of lines.entries()) {
207
+ const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
208
+ if (!match)
209
+ continue;
210
+ if (match[2].trim().toLowerCase() !== target)
211
+ continue;
212
+ start = index;
213
+ level = match[1].length;
214
+ break;
215
+ }
216
+ if (start < 0)
217
+ return "";
218
+ let end = lines.length;
219
+ for (let index = start + 1; index < lines.length; index++) {
220
+ const match = lines[index].match(/^(#{1,6})\s+/);
221
+ if (match && match[1].length <= level) {
222
+ end = index;
223
+ break;
224
+ }
225
+ }
226
+ return lines.slice(start, end).join("\n").trimEnd();
227
+ }
228
+ function getCoreRulesSection(heading) {
229
+ const rulesPath = makePath(PACKAGE_ROOT, "core-rules", "AGENTS.md");
230
+ if (!existsSync(rulesPath))
231
+ return "";
232
+ return extractMarkdownHeadingSection(readFileSync(rulesPath, "utf8"), heading);
233
+ }
234
+ function renderTicketCreateTemplateGuide(docsLanguage = "ko") {
235
+ const templateDir = makePath(PACKAGE_ROOT, "templates");
236
+ const templatePath = selectLocalizedTemplatePath(templateDir, "TICKET_TEMPLATE.md", docsLanguage);
237
+ const template = readFileSync(templatePath, "utf8");
238
+ const frontmatter = YAML.stringify(sanitizeFrontMatterMeta({
239
+ id: "generated-ticket-id",
240
+ title: "ticket-title",
241
+ breadcrumb: "workspace-name",
242
+ phase: 1,
243
+ status: "open",
244
+ workflowSource: "ticket-create",
245
+ docsLanguage,
246
+ summary: "concrete-summary",
247
+ priority: "P2",
248
+ tags: []
249
+ })).trim();
250
+ return ejs.render(template, {
251
+ frontmatter,
252
+ meta: { title: "ticket-title" }
253
+ }).trimEnd();
254
+ }
255
+ function readCliSurfaceDocument(name) {
256
+ const surfacePath = makePath(PACKAGE_ROOT, "docs", "cli-surfaces", `${name}.md`);
257
+ if (!existsSync(surfacePath)) {
258
+ throw new Error(`CLI surface document not found: ${surfacePath}`);
259
+ }
260
+ return readFileSync(surfacePath, "utf8");
261
+ }
262
+ function renderCliSurfaceDocument(name, replacements = {}) {
263
+ let content = readCliSurfaceDocument(name);
264
+ content = content.replace(/\{\{CORE:([^}]+)\}\}/g, (_match, heading) => getCoreRulesSection(String(heading || "").trim()).trimEnd());
265
+ for (const [key, value] of Object.entries(replacements)) {
266
+ const str = String(value ?? "").trim();
267
+ if (!str) {
268
+ // 값이 비면 해당 플레이스홀더와 직전 헤더 라인(## ...)까지 제거
269
+ content = content.replace(new RegExp(`^##[^\n]*\n\\{\\{${key}\\}\\}\n?`, "m"), "");
270
+ content = content.replaceAll(`{{${key}}}`, "");
271
+ }
272
+ else {
273
+ content = content.replaceAll(`{{${key}}}`, str);
274
+ }
275
+ }
276
+ // 연속된 빈 줄 3개 이상 → 2개로 정리
277
+ content = content.replace(/\n{3,}/g, "\n\n");
278
+ return content.trimEnd();
279
+ }
280
+ // #694: renderTicketWorkflowSummary 제거 — 워크플로 노드/전이는 XState 머신(SSOT)이고
281
+ // rules 출력에서 표 덤프를 뺐으므로 더 이상 쓰이지 않는다.
282
+ function readProjectRuleSurface(cwd, platform = null) {
283
+ // native 플랫폼(claude)은 PROJECT_RULE.md가 CLAUDE.md 컨텍스트에 이미 로드됨 — 중복 embed 스킵.
284
+ const detectedPlatform = platform || detectCurrentPlatform();
285
+ if (detectedPlatform === "claude")
286
+ return "";
287
+ if (!cwd)
288
+ return "";
289
+ const projectRulePath = makePath(resolveHomeTicketDirForWorkspace(cwd), "PROJECT_RULE.md");
290
+ if (!existsSync(projectRulePath))
291
+ return "";
292
+ return readFileSync(projectRulePath, "utf8").trimEnd();
293
+ }
294
+ function readProjectPolicySurface(cwd, opts = {}) {
295
+ // native 플랫폼(claude)은 활성 티켓 없을 때 guardrails 전체 나열 스킵.
296
+ const detectedPlatform = opts.platform || detectCurrentPlatform();
297
+ if (detectedPlatform === "claude" && !opts.activePolicyRole)
298
+ return "";
299
+ if (!cwd)
300
+ return "";
301
+ const policyDir = makePath(resolveHomeTicketDirForWorkspace(cwd), "project-guardrails");
302
+ if (!existsSync(policyDir))
303
+ return "";
304
+ const docsLanguage = resolveDocsLanguage(normalizeDocsLanguage(opts.docsLanguage || "ko"));
305
+ const allFiles = readdirSync(policyDir).filter(name => name.endsWith(".md"));
306
+ const policies = new Set();
307
+ const fileMap = new Map();
308
+ for (const file of allFiles) {
309
+ let baseName = file.replace(/\.md$/, "");
310
+ if (/\.[a-z]{2}$/.test(baseName)) {
311
+ baseName = baseName.slice(0, -3);
312
+ }
313
+ policies.add(baseName);
314
+ if (!fileMap.has(baseName))
315
+ fileMap.set(baseName, []);
316
+ fileMap.get(baseName).push(file);
317
+ }
318
+ const selectedFiles = [];
319
+ for (const baseName of Array.from(policies).sort()) {
320
+ if (opts.activePolicyRole && baseName !== opts.activePolicyRole) {
321
+ continue;
322
+ }
323
+ const filesForPolicy = fileMap.get(baseName);
324
+ const localized = `${baseName}.${docsLanguage}.md`;
325
+ const defaultFile = `${baseName}.md`;
326
+ if (filesForPolicy.includes(localized)) {
327
+ selectedFiles.push(localized);
328
+ }
329
+ else if (filesForPolicy.includes(defaultFile)) {
330
+ selectedFiles.push(defaultFile);
331
+ }
332
+ else {
333
+ selectedFiles.push(filesForPolicy.sort()[0]);
334
+ }
335
+ }
336
+ return selectedFiles.map(name => {
337
+ const content = readFileSync(makePath(policyDir, name), "utf8").trimEnd();
338
+ return `## ${name}\n\n${content}`;
339
+ }).join("\n\n");
340
+ }
341
+ // #685: readProjectMemorySurface 제거 — project-memory.md는 진행 상태를 담던 무의미한
342
+ // 레이어였고 state는 .md 프론트매터가 SSOT다. rules 출력에서 PROJECT_MEMORY 섹션 폐지.
343
+ export function buildTicketCreateGuide(opts = {}) {
344
+ const docsLanguage = resolveDocsLanguage(normalizeDocsLanguage(opts.docsLanguage || "ko"));
345
+ const renderedTemplate = renderTicketCreateTemplateGuide(docsLanguage);
346
+ const surfaceName = docsLanguage === "ko" ? "ticket-create.ko" : "ticket-create";
347
+ return renderCliSurfaceDocument(surfaceName, {
348
+ TICKET_TEMPLATE: renderedTemplate
349
+ });
350
+ }
351
+ // 티켓의 tags/summary에서 작업 도메인을 뽑아 역할 프롬프트에 주입할 한 줄 지시로 변환.
352
+ // 매칭 규칙은 작게 시작해 확장한다(설계 §4.2). 매칭 없으면 빈 문자열(역할 기본 문안만).
353
+ const DOMAIN_FOCUS_RULES = [
354
+ { keys: ["codegen", "idl", "generate", "생성물"], focus: "[DOMAIN_FOCUS: codegen] 코드 생성물 관련. 생성물(generated/·idl)을 직접 수정하지 말고 소스→재생성 경로로만. 빌드타임 타입 확정을 런타임 registry/convention 우회로 대체 금지." },
355
+ { keys: ["refactor", "리팩터", "리팩토링"], focus: "[DOMAIN_FOCUS: refactor] 리팩터. 동작 보존이 최우선 — 변경 전후 동치성을 검증으로 보이고, 스코프를 최소·국소로 유지." },
356
+ { keys: ["ui", "webview", "widget", "css", "레이아웃", "디자인"], focus: "[DOMAIN_FOCUS: ui/design] UI/디자인 산출물. 일관성·정보위계·재사용·접근성을 1급 기준으로. \"동작하니 됐다\" 금지." },
357
+ { keys: ["bug", "fix", "버그", "수정"], focus: "[DOMAIN_FOCUS: bugfix] 버그 수정. 근본 원인을 먼저 특정하고, 재현 → 수정 → 회귀 방지 순서. 증상만 가리는 땜질 금지." },
358
+ ];
359
+ function deriveDomainFocus(entry) {
360
+ if (!entry)
361
+ return "";
362
+ const tags = Array.isArray(entry.tags) ? entry.tags.map(t => String(t).toLowerCase()) : [];
363
+ const hay = `${tags.join(" ")} ${String(entry.summary || "")} ${String(entry.title || "")}`.toLowerCase();
364
+ for (const rule of DOMAIN_FOCUS_RULES) {
365
+ if (rule.keys.some(k => hay.includes(k)))
366
+ return rule.focus;
367
+ }
368
+ return "";
369
+ }
370
+ // 워크스페이스별 기술 스택/관습 프로필 SSOT: <workspace>/.deuk-agent/workspace-profile.md.
371
+ // 있으면 역할 프롬프트에 주입, 없으면 빈 문자열(역할 기본 문안만).
372
+ function readWorkspaceProfile(cwd, workspace, platform = null) {
373
+ // 워크스페이스 프로필 SSOT = PROJECT_RULE.md (홈 스토어, reconcile이 보존하는 알려진 파일).
374
+ // 별도 파일을 만들지 않는다 — 설계상 PROJECT_RULE.md가 워크스페이스 규칙+내부기준문서
375
+ // 포인터를 겸한다.
376
+ //
377
+ // 중복 회피: non-claude 플랫폼은 surface가 이미 `## Project Rules`로 PROJECT_RULE.md
378
+ // 전문을 보여주므로(readProjectRuleSurface) 여기선 프로필을 다시 싣지 않는다. claude는
379
+ // `## Project Rules`를 스킵(CLAUDE.md 중복 방지)하므로 ROLE_CONTEXT가 프로필을 담당한다.
380
+ const detectedPlatform = platform || detectCurrentPlatform();
381
+ if (detectedPlatform !== "claude")
382
+ return "";
383
+ if (!cwd)
384
+ return "";
385
+ try {
386
+ const p = makePath(resolveHomeTicketDirForWorkspace(cwd), "PROJECT_RULE.md");
387
+ if (existsSync(p)) {
388
+ const raw = stripLeadingFrontMatter(readFileSync(p, "utf8")).trim();
389
+ if (raw)
390
+ return `[WORKSPACE_PROFILE: ${workspace || "local"}]\n${raw}`;
391
+ }
392
+ }
393
+ catch { /* ignore */ }
394
+ return "";
395
+ }
396
+ // 역할 동적 컨텍스트 블록. 도메인/프로필 중 있는 것만 모아 한 섹션으로 낸다.
397
+ // 둘 다 없으면 빈 문자열(템플릿에서 빈 줄로 흡수).
398
+ function buildRoleContextBlock(domainFocus, workspaceProfile) {
399
+ const parts = [domainFocus, workspaceProfile].map(s => String(s || "").trim()).filter(Boolean);
400
+ if (parts.length === 0)
401
+ return "";
402
+ return `## Role Context (이번 작업 동적 컨텍스트)\n\n현재 페이즈 역할 레이어에 다음을 함께 적용한다:\n\n${parts.join("\n\n")}`;
403
+ }
404
+ export function buildTicketRulesGuide(opts = {}) {
405
+ const surface = String(opts.ruleSurface || "ticket").trim() || "ticket";
406
+ if (surface === "ticket") {
407
+ // #694: 현재 노드 영역(area) 결정 — 활성 티켓이 있으면 그 phase(phase1~4),
408
+ // 없으면 진입 영역 rules. 스킬은 이 영역에 바인딩된 것만 노출(노드별 도구 바인딩).
409
+ let activePolicyRole = null;
410
+ let area = "rules";
411
+ let activeEntry = null;
412
+ try {
413
+ const index = readTicketIndexJson(opts.cwd);
414
+ const activeId = index?.activeTicketId || null;
415
+ if (activeId) {
416
+ const entry = (index?.entries || []).find(e => e.id === activeId);
417
+ if (entry) {
418
+ activeEntry = entry;
419
+ const runtimeState = resolveTicketWorkflowRuntimeState(entry, entry.status);
420
+ activePolicyRole = runtimeState?.state?.policyRole ?? "analysis";
421
+ area = runtimeState?.name || `phase${entry.phase || 1}`;
422
+ }
423
+ }
424
+ }
425
+ catch { /* ignore */ }
426
+ const guardrailsOpts = { ...opts, activePolicyRole };
427
+ // 역할 동적 컨텍스트: 티켓 APC/도메인 + 워크스페이스 프로필. 네이티브 플랫폼(claude)은
428
+ // 스킬 본문을 정적 파일로 깔아 본문 치환이 안 통하므로, 동적 컨텍스트는 rules surface의
429
+ // 별도 인라인 블록(ROLE_CONTEXT)으로 항상 출력한다(NEXT_ACTION과 동일 방식).
430
+ const roleContext = buildRoleContextBlock(deriveDomainFocus(activeEntry), readWorkspaceProfile(opts.cwd, opts.workspace, opts.platform));
431
+ // #685: PROJECT_MEMORY 제거(.md가 state SSOT). PROJECT_RULES는 도메인 케이스만.
432
+ // #694: TICKET_WORKFLOW 표 덤프 제거 — 워크플로 노드/전이는 XState 머신(SSOT)이고
433
+ // 표를 매번 쏟는 건 노이즈다. 스킬은 현재 area 바인딩분만 노출.
434
+ return renderCliSurfaceDocument("ticket", {
435
+ REQUESTED_SURFACE: surface,
436
+ PROJECT_RULES: readProjectRuleSurface(opts.cwd, opts.platform),
437
+ PROJECT_GUARDRAILS: readProjectPolicySurface(opts.cwd, { ...guardrailsOpts, platform: opts.platform }),
438
+ AGENT_SKILLS: getActiveSkillsSurface(opts.cwd, opts.platform, area),
439
+ ROLE_CONTEXT: roleContext,
440
+ NEXT_ACTION: buildNextActionBlock(opts)
441
+ });
442
+ }
443
+ throw new Error(`Unknown ticket rules surface: ${surface}`);
444
+ }
445
+ // WSL2 환경에서는 Node가 win32 빌드여도 bash tool 사용 — WSLENV/WSL_DISTRO_NAME으로 감지.
446
+ function isWsl() {
447
+ return !!(process.env.WSLENV || process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
448
+ }
449
+ // #685: OS 추측 폴백 제거 — rules/move 출력은 에이전트가 그대로 실행할 노드 프롬프트이고,
450
+ // 에이전트는 항상 Bash tool 전용이다. process.platform/WSL 추측으로 PowerShell 블록을
451
+ // 내보내던 분기가 폴백의 근원이었으므로, 셸은 항상 bash로 고정한다.
452
+ function isWindowsPlatform(opts = {}) {
453
+ return false;
454
+ }
455
+ function isAgentOnWindows(opts = {}) {
456
+ return false;
457
+ }
458
+ // #690: 노드 프롬프트는 docs/cli-surfaces/state/*.md 가 SSOT다. 이 헬퍼는 해당 .md를
459
+ // 읽어 {{PLACEHOLDER}}만 치환해 emit한다. 코드에 프롬프트 문자열을 하드코딩하지 않는다.
460
+ function emitNodePrompt(name, replacements = {}, lang = "ko") {
461
+ const text = loadStatePrompt(name, lang);
462
+ if (!text)
463
+ return "";
464
+ let out = text;
465
+ for (const [key, value] of Object.entries(replacements)) {
466
+ out = out.replaceAll(`{{${key}}}`, String(value ?? ""));
467
+ }
468
+ // 미치환 placeholder 잔여 라인 정리 + 연속 빈 줄 축소
469
+ return out.replace(/\n{3,}/g, "\n\n").trimEnd();
470
+ }
471
+ // #690: LangGraph 라우터 — registry 전체에서 active ticket을 스캔해 현재 entry 노드 키와
472
+ // 그 노드의 프롬프트 치환 컨텍스트를 결정한다. 순수 판정만 하며 프롬프트 문자열을 만들지 않는다.
473
+ // #740: cwd 기반 workspace 감지 제거 — init 외 cwd 사용 금지 원칙. registry(--workspace)가 SSOT.
474
+ function routeEntryNode(opts = {}) {
475
+ // registry 전체 워크스페이스 목록
476
+ let registered = [];
477
+ try {
478
+ const candidates = loadWorkspaceCandidates("", { platform: "any", homeDir: opts.homeDir });
479
+ registered = (candidates?.workspaces || []).map(w => w.id).filter(Boolean).sort();
480
+ }
481
+ catch { /* registry unreadable */ }
482
+ if (registered.length === 0) {
483
+ return { node: "entry-free", ctx: {} };
484
+ }
485
+ // #743: 세션 쿠키 없이 전체 workspace 순회로 active ticket을 잡는 폴백 제거.
486
+ // sessionId 쿠키가 있으면 해당 workspace만 조회. 쿠키 없으면 entry-no-workspace로 떨어짐.
487
+ let activeId = null, activePhase = null, activeStatus = null, activePath = null, activeWsCwd = null;
488
+ const sessionCookie = opts.sessionId ? readWorkspaceCookie("", opts.sessionId, { homeDir: opts.homeDir }) : null;
489
+ const sessionWorkspacePath = sessionCookie?.workspacePath || opts.cwd || null;
490
+ if (sessionWorkspacePath) {
491
+ try {
492
+ const candidates = loadWorkspaceCandidates("", { platform: "any", homeDir: opts.homeDir });
493
+ const matchedWs = (candidates?.workspaces || []).find(w => w.path === sessionWorkspacePath);
494
+ const wsCwd = matchedWs?.path || sessionWorkspacePath;
495
+ const index = readTicketIndexJson(wsCwd);
496
+ const id = index?.activeTicketId || null;
497
+ if (id) {
498
+ const entry = (index?.entries || []).find(e => e.id === id);
499
+ if (entry) {
500
+ activeId = id;
501
+ activePhase = entry?.phase ?? null;
502
+ activeStatus = entry?.status ?? null;
503
+ activePath = entry?.path ?? null;
504
+ activeWsCwd = wsCwd;
505
+ }
506
+ }
507
+ }
508
+ catch { /* skip */ }
509
+ }
510
+ if (activeId && activeWsCwd) {
511
+ const advance = buildPhaseAdvanceHint(activeId, activePhase, activeStatus, resolveDocsLanguage(opts.docsLanguage || "auto"));
512
+ let ticketDisplay = `**${activeId}** (phase ${activePhase}, ${activeStatus})`;
513
+ if (activePath) {
514
+ const keyDir = detectConsumerTicketDir(activeWsCwd) || activeWsCwd;
515
+ ticketDisplay = formatTicketPhaseLine(activeId, makePath(keyDir, activePath), activePhase, activeWsCwd, opts.invocationCwd || activeWsCwd);
516
+ }
517
+ return { node: "entry-in-progress", ctx: {
518
+ TICKET_DISPLAY: ticketDisplay,
519
+ TICKET_ID: activeId,
520
+ PHASE_ADVANCE: advance
521
+ } };
522
+ }
523
+ return { node: "entry-no-workspace", ctx: {
524
+ WORKSPACE_COUNT: registered.length,
525
+ WORKSPACE_LIST: registered.map(id => ` - ${id}`).join("\n"),
526
+ FIRST_WORKSPACE: registered[0]
527
+ } };
528
+ }
529
+ // #690: 라우터가 고른 노드를 .md 프롬프트(SSOT)로 emit. router(판정)/emit(출력) 분리.
530
+ function buildNextActionBlock(opts = {}) {
531
+ const lang = resolveDocsLanguage(opts.docsLanguage || "auto");
532
+ const { node, ctx } = routeEntryNode(opts);
533
+ return emitNodePrompt(node, ctx, lang);
534
+ }
535
+ // Phase-aware hint: an active ticket should not stall in coding.
536
+ // Surface the next workflow transition (review/verification → completion)
537
+ // so the agent advances the ticket instead of re-`use`-ing it forever.
538
+ function loadStatePrompt(stateName, lang = "en") {
539
+ const p = resolve(PACKAGE_ROOT, "docs", "cli-surfaces", "state", `${stateName}.${lang}.md`);
540
+ try {
541
+ return readFileSync(p, "utf8").trim();
542
+ }
543
+ catch {
544
+ return null;
545
+ }
546
+ }
547
+ function loadStateTemplate(stateName, lang = "en") {
548
+ const p = resolve(PACKAGE_ROOT, "docs", "cli-surfaces", "state-template", `${stateName}.${lang}.md`);
549
+ try {
550
+ return readFileSync(p, "utf8").trim();
551
+ }
552
+ catch {
553
+ return null;
554
+ }
555
+ }
556
+ function buildPhaseAdvanceHint(activeId, phase, status, lang = "en") {
557
+ if (isTerminalTicketStatus(status))
558
+ return "";
559
+ const phaseNum = normalizeTicketPhaseNumber(phase);
560
+ const stateName = `phase${phaseNum >= 4 ? 4 : phaseNum}`;
561
+ const state = TICKET_WORKFLOW.states?.[stateName];
562
+ const transitions = state?.transitions || [];
563
+ if (transitions.length === 0)
564
+ return "";
565
+ // Label each transition: first=forward, end=early-exit, lower phase=rollback, phase1=reopen
566
+ const routeLines = transitions.map((target, i) => {
567
+ let label;
568
+ if (target === "end")
569
+ label = "early-exit";
570
+ else if (i === 0)
571
+ label = "forward";
572
+ else {
573
+ const targetPhase = TICKET_WORKFLOW.states?.[target]?.phase;
574
+ label = targetPhase < phaseNum ? (targetPhase <= 1 ? "reopen" : "rollback") : "forward";
575
+ }
576
+ const targetPhaseNum = TICKET_WORKFLOW.states?.[target]?.phase;
577
+ const cmd = target === "end"
578
+ ? `deuk-agent-flow ticket move --id ${activeId} --to end --non-interactive`
579
+ : `deuk-agent-flow ticket move --id ${activeId} --phase ${targetPhaseNum} --non-interactive`;
580
+ return ` → ${target.padEnd(6)} (${label}): ${cmd}`;
581
+ });
582
+ // Discard hint if allowed
583
+ if ((state?.actions || []).includes("discard")) {
584
+ routeLines.push(` → discard (abandon): deuk-agent-flow ticket discard --id ${activeId} --workspace <ws> --non-interactive`);
585
+ }
586
+ // Slot hint from forward target template
587
+ const forward = transitions[0];
588
+ const nextStateName = forward !== "end" ? `phase${TICKET_WORKFLOW.states?.[forward]?.phase}` : "end";
589
+ const statePrompt = loadStatePrompt(nextStateName, lang) || loadStatePrompt(stateName, lang) || "";
590
+ const templateContent = loadStateTemplate(nextStateName, lang) || "";
591
+ const slotLines = templateContent
592
+ .split("\n")
593
+ .filter(l => /^- \w.*:/.test(l.trim()))
594
+ .map(l => l.trim());
595
+ const slotsSection = slotLines.length > 0
596
+ ? `\n${cliText("ticket.phase.fillSlots", { locale: lang })}\n${slotLines.map(l => ` ${l}`).join("\n")}`
597
+ : "";
598
+ // #690: 셸은 항상 bash 고정(에이전트는 Bash tool 전용). 노드 전이 프롬프트 본문은
599
+ // loadStatePrompt로 .md(SSOT)에서 가져온다.
600
+ return `
601
+
602
+ Next phase (${stateName} → ${forward}): ${statePrompt}${slotsSection}
603
+ \`\`\`bash
604
+ ${routeLines.join("\n")}
605
+ \`\`\``;
606
+ }
607
+ export function runTicketRules(opts = {}) {
608
+ // ticket status 등 다른 명령과 동일하게 opts.cwd를 소비자 티켓 루트로 리매핑한다.
609
+ // 이게 없으면 readTicketIndexJson이 빈 entries를 반환해 activeTicketId가 null이 되고,
610
+ // rules 출력이 active 티켓을 표현하지 못한 채 "No active ticket"으로 빠진다.
611
+ applyTicketRootContext(opts);
612
+ console.log(buildTicketRulesGuide(opts));
613
+ }
614
+ function buildPlanBodyRepairPayload(reasons = []) {
615
+ const uniqueReasons = [...new Set(reasons)];
616
+ return {
617
+ ok: false,
618
+ error: "ticket_create_phase1_plan_invalid",
619
+ missingOrIncomplete: uniqueReasons,
620
+ headings: phase1SectionsForType().map(section => `## ${section}`),
621
+ repair: {
622
+ command: 'deuk-agent-flow ticket create --title <title> --summary "<summary>" --plan-body "<filled body>" --non-interactive --require-filled --json',
623
+ instruction: "Fill only the missing/incomplete sections and rerun with the same body.",
624
+ skeleton: buildPhase1PlanBodySkeleton()
625
+ }
626
+ };
627
+ }
628
+ function buildPlanBodyRequiredError(reasons = [], opts = {}) {
629
+ if (opts.json) {
630
+ return JSON.stringify(buildPlanBodyRepairPayload(reasons), null, 2);
631
+ }
632
+ return buildPlanBodyRequiredMessage(reasons);
633
+ }
634
+ export function getAutoCloseDecision(meta, content) {
635
+ const checked = (content.match(/- \[x\]/gi) || []).length;
636
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
637
+ const total = checked + unchecked;
638
+ const allDone = total > 0 && unchecked === 0;
639
+ const phase = Number(meta.phase || 1);
640
+ if (phase >= 3 && allDone) {
641
+ return { shouldClose: true, reason: `phase=${phase}, tasks=${checked}/${total} done` };
642
+ }
643
+ if (allDone) {
644
+ return { shouldClose: true, reason: `all tasks done (${checked}/${total}), phase=${phase}` };
645
+ }
646
+ const workflowReasons = getCloseWorkflowReasons(meta, content);
647
+ if (phase >= 2 && workflowReasons.length === 0 && hasTicketCompletionEvidence(content)) {
648
+ return { shouldClose: true, reason: `phase=${phase}, completion evidence present` };
649
+ }
650
+ return { shouldClose: false, reason: `phase=${phase}, tasks=${checked}/${total} done` };
651
+ }
652
+ function validateClaimAgainstTicketContent(meta, content, claim) {
653
+ const reasons = [];
654
+ const claimTerms = tokenizeClaimText(claim);
655
+ if (claimTerms.length === 0) {
656
+ reasons.push("claim_terms_missing");
657
+ return reasons;
658
+ }
659
+ const coverageText = collectClaimTargetSections(content);
660
+ const coverage = buildClaimCoverageSummary(claimTerms, coverageText);
661
+ const matchingRate = coverage.matched.length / Math.max(coverage.total, 1);
662
+ if (matchingRate < 0.33) {
663
+ reasons.push(`claim_coverage_missing:${coverage.matched.length}/${coverage.total}`);
664
+ }
665
+ if (!meta.summary || hasPlaceholderTokens(meta.summary)) {
666
+ reasons.push("claim_ticket_summary_missing");
667
+ }
668
+ const phase1Missing = getAnalysisDesignIncompleteReasons(meta, content);
669
+ if (phase1Missing.length > 0) {
670
+ reasons.push("claim_ticket_incomplete_record");
671
+ }
672
+ return reasons;
673
+ }
674
+ function getClaimEvidenceResult(target, meta, content, claim) {
675
+ const reasons = validateClaimAgainstTicketContent(meta, content, claim);
676
+ const claimTerms = tokenizeClaimText(claim);
677
+ const coverage = buildClaimCoverageSummary(claimTerms, collectClaimTargetSections(content));
678
+ const phase1Missing = getAnalysisDesignIncompleteReasons(meta, content);
679
+ const slots = {
680
+ claimTermsPresent: claimTerms.length > 0,
681
+ claimCoverage: reasons.every(reason => !String(reason).startsWith("claim_coverage_missing")),
682
+ ticketSummaryPresent: Boolean(meta.summary) && !hasPlaceholderTokens(meta.summary),
683
+ ticketRecordComplete: phase1Missing.length === 0
684
+ };
685
+ return {
686
+ ok: reasons.length === 0,
687
+ reasons,
688
+ ticket: target.id,
689
+ path: target.path,
690
+ claim,
691
+ claimTerms: coverage.total,
692
+ coveredTerms: coverage.matched.length,
693
+ matchedTerms: coverage.matched,
694
+ missRate: Number((coverage.missRate * 100).toFixed(1)),
695
+ sections: {
696
+ analysis: extractMarkdownSection(content, "Analysis").trim(),
697
+ direction: extractMarkdownSection(content, "Direction").trim()
698
+ },
699
+ docmeta: {
700
+ document_type: "ticket_validation",
701
+ document_subtype: "claim_evidence_gate",
702
+ ticket_id: target.id,
703
+ source_contract: {
704
+ required_slots: Object.keys(slots),
705
+ metadata_fields: ["ticket_id", "claim", "claim_terms", "covered_terms", "source_path"]
706
+ },
707
+ slots,
708
+ slot_source_map: {
709
+ claimTermsPresent: { source: "--claim" },
710
+ claimCoverage: { source: "ticket analysis/design/verification sections", covered_terms: coverage.matched },
711
+ ticketSummaryPresent: { source: "ticket frontmatter summary" },
712
+ ticketRecordComplete: { source: "ticket workflow sections", missing: phase1Missing }
713
+ },
714
+ validation: {
715
+ status: reasons.length === 0 ? "PASS" : "NEEDS_FIX",
716
+ errors: reasons
717
+ },
718
+ output_status: reasons.length === 0 ? "evidence_allowed" : "evidence_blocked"
719
+ }
720
+ };
721
+ }
722
+ const IMPLEMENTATION_CLAIM_PATTERNS = [
723
+ /\b(?:fix|fixed|implement|implemented|apply|applied|change(?:d)?|patch(?:ed)?|resolved?)\b/i,
724
+ /(수정|구현|적용|변경|패치|해결)(?:했|됨|완료|함)?/i
725
+ ];
726
+ function claimImpliesCodeChange(text) {
727
+ const src = String(text || "").trim();
728
+ if (!src)
729
+ return false;
730
+ return IMPLEMENTATION_CLAIM_PATTERNS.some(pattern => pattern.test(src));
731
+ }
732
+ function isTicketOwnedPath(relPath) {
733
+ const normalized = toPosixPath(String(relPath || ""));
734
+ return normalized.startsWith(`${DEUK_ROOT_DIR}/tickets/`) || normalized.startsWith(`${DEUK_ROOT_DIR}/docs/`);
735
+ }
736
+ function collectChangedSourceFiles(cwd, changedFilesOverride = null) {
737
+ const changed = Array.isArray(changedFilesOverride) ? changedFilesOverride : collectChangedFiles(cwd);
738
+ return changed.filter(relPath => !isTicketOwnedPath(relPath));
739
+ }
740
+ function extractLikelyAffectedFiles(text) {
741
+ const matches = String(text || "").match(/\b[\w./-]+\.(?:[cm]?[jt]s|tsx?|jsx?|mjs|cjs|json|ya?ml|ejs|rs|py|java|cs|cpp|hpp|h|ex|exs|go|kt|swift|rb|php|sh)\b/gi) || [];
742
+ return [...new Set(matches.map(match => toPosixPath(match.trim()).replace(/^\.\//, "")))];
743
+ }
744
+ export function getImplementationClaimGuardResult(cwd, { claim = "", content = "", changedFiles = null } = {}) {
745
+ const changedSourceFiles = collectChangedSourceFiles(cwd, changedFiles);
746
+ const effectiveClaim = String(claim || "").trim();
747
+ const verificationOutcome = extractMarkdownSection(content, "Verification Outcome");
748
+ const candidateText = [effectiveClaim, verificationOutcome].filter(Boolean).join("\n");
749
+ if (!claimImpliesCodeChange(candidateText)) {
750
+ return { ok: true, changedFiles: changedSourceFiles, affectedFiles: [] };
751
+ }
752
+ const affectedFiles = extractLikelyAffectedFiles(candidateText);
753
+ const changedLookup = new Set(changedSourceFiles.map(file => toPosixPath(resolve(cwd, file)).replace(/^\.\//, "")));
754
+ const additionalChanged = new Set();
755
+ const changedRepoCache = new Map();
756
+ for (const candidate of affectedFiles) {
757
+ const candidateText = String(candidate || "").trim();
758
+ if (!candidateText)
759
+ continue;
760
+ const absCandidate = candidateText.startsWith("/") ? resolve(candidateText) : resolve(cwd, candidateText);
761
+ let cursor = absCandidate;
762
+ if (!existsSync(cursor) || !statSync(cursor).isDirectory()) {
763
+ cursor = dirname(cursor);
764
+ }
765
+ while (cursor && dirname(cursor) !== cursor) {
766
+ if (existsSync(makePath(cursor, ".git"))) {
767
+ if (!changedRepoCache.has(cursor)) {
768
+ changedRepoCache.set(cursor, new Set(collectChangedFiles(cursor).map(file => toPosixPath(resolve(cursor, file)))));
769
+ }
770
+ const repoChanged = changedRepoCache.get(cursor);
771
+ const normalizedCandidate = toPosixPath(absCandidate);
772
+ if (repoChanged.has(normalizedCandidate)) {
773
+ additionalChanged.add(normalizedCandidate);
774
+ }
775
+ break;
776
+ }
777
+ cursor = dirname(cursor);
778
+ }
779
+ }
780
+ const fallbackOverlap = affectedFiles.filter(file => {
781
+ const absCandidate = String(file || "").trim();
782
+ if (!absCandidate)
783
+ return false;
784
+ const candidatePath = toPosixPath(absCandidate.startsWith("/") ? resolve(absCandidate) : resolve(cwd, absCandidate));
785
+ return changedLookup.has(candidatePath) || additionalChanged.has(candidatePath);
786
+ });
787
+ const reasons = [];
788
+ if (changedLookup.size === 0) {
789
+ reasons.push("implementation_changed_files_missing");
790
+ }
791
+ if (affectedFiles.length > 0 && fallbackOverlap.length === 0) {
792
+ reasons.push("implementation_affected_files_not_changed");
793
+ }
794
+ return {
795
+ ok: reasons.length === 0,
796
+ reasons,
797
+ changedFiles: [...changedSourceFiles, ...additionalChanged],
798
+ affectedFiles,
799
+ overlap: fallbackOverlap
800
+ };
801
+ }
802
+ function isCompactTicketOutput(opts = {}) {
803
+ return Boolean(opts.compact || opts.nonInteractive);
804
+ }
805
+ function printUsageReminder(cwd, opts = {}) {
806
+ if (isCompactTicketOutput(opts) || opts.pathOnly)
807
+ return;
808
+ const reminder = getUsageReminderLine(cwd);
809
+ if (reminder) {
810
+ console.log(reminder);
811
+ }
812
+ }
813
+ function printCreateApprovalGate(ticketId, opts = {}, scopeSummary = "") {
814
+ void ticketId;
815
+ void opts;
816
+ void scopeSummary;
817
+ }
818
+ function inferWorkspaceFromTicketPath(ticketAbsPath = "") {
819
+ const normalized = resolve(String(ticketAbsPath || ""));
820
+ const parts = normalized.split(sep);
821
+ const idx = parts.lastIndexOf(DEUK_ROOT_DIR);
822
+ if (idx > 0) {
823
+ const projectLabel = parts[idx - 1];
824
+ if (projectLabel)
825
+ return projectLabel;
826
+ }
827
+ return "";
828
+ }
829
+ function resolveTicketWorkspaceLabel(cwd, absPath) {
830
+ const workspaceContextLabel = resolveWorkspaceContext(cwd).breadcrumb;
831
+ const pathLabel = inferWorkspaceFromTicketPath(absPath);
832
+ return sanitizeTicketWorkspaceLabel(workspaceContextLabel) || sanitizeTicketWorkspaceLabel(pathLabel);
833
+ }
834
+ function formatTicketStartLine(ticketId, absPath, cwd = "", invocationCwd = "") {
835
+ return formatTicketFlowLine(resolveTicketWorkspaceLabel(cwd, absPath), FLOW_SURFACE_STATUS.start, ticketId, absPath, invocationCwd || cwd);
836
+ }
837
+ function formatTicketUpdateLine(ticketId, absPath, cwd = "", invocationCwd = "") {
838
+ return formatTicketFlowLine(resolveTicketWorkspaceLabel(cwd, absPath), FLOW_SURFACE_STATUS.adjust, ticketId, absPath, invocationCwd || cwd);
839
+ }
840
+ function formatTicketPhaseLine(ticketId, absPath, phase, cwd = "", invocationCwd = "") {
841
+ const normalizedPhase = normalizeTicketPhaseNumber(phase);
842
+ const statusLabel = normalizedPhase <= 1
843
+ ? FLOW_SURFACE_STATUS.adjust
844
+ : FLOW_SURFACE_STATUS.progressActive;
845
+ return formatTicketFlowLine(resolveTicketWorkspaceLabel(cwd, absPath), statusLabel, ticketId, absPath, invocationCwd || cwd);
846
+ }
847
+ function formatTicketEndLine(ticketId, absPath, cwd = "", invocationCwd = "") {
848
+ return formatTicketFlowLine(resolveTicketWorkspaceLabel(cwd, absPath), FLOW_SURFACE_STATUS.end, ticketId, absPath, invocationCwd || cwd);
849
+ }
850
+ function formatTicketStatusLine(label, ticketId, absPath) {
851
+ return `Ticket ${label}: [${ticketId}](${absPath})`;
852
+ }
853
+ function ticketWorkflowOptions(opts = {}, replacements = {}) {
854
+ return {
855
+ docsLanguage: opts.docsLanguage,
856
+ resolveLanguage: resolveDocsLanguage,
857
+ normalizeDocsLanguage,
858
+ cwd: opts.cwd,
859
+ replacements,
860
+ runtimeContext: opts.runtimeContext,
861
+ surfaceMode: opts.verbose ? "full" : "recipe"
862
+ };
863
+ }
864
+ function buildTicketRuntimeContext(ticketId, absPath, meta = {}, fallback = {}) {
865
+ const runtimePath = absPath ? toPosixPath(resolve(absPath)) : "";
866
+ const connectionState = fallback.cwd ? describeTicketConnectionState(fallback.cwd) : "";
867
+ const workspaceLabel = fallback.cwd ? resolveTicketWorkspaceLabel(fallback.cwd, absPath || "") : "";
868
+ return {
869
+ ticketId,
870
+ hostWorkspaceLabel: fallback.hostWorkspaceLabel || "",
871
+ workspaceLabel,
872
+ path: runtimePath,
873
+ phase: Number(meta.phase || fallback.phase || 1),
874
+ status: String(meta.status || fallback.status || "open").toLowerCase(),
875
+ summary: meta.summary || fallback.summary || "",
876
+ connectionState,
877
+ reasons: fallback.reasons || [],
878
+ nextAction: fallback.nextAction || ""
879
+ };
880
+ }
881
+ function describeTicketConnectionState(cwd) {
882
+ const mcpStatuses = collectMcpConfigStatuses(cwd);
883
+ if (mcpStatuses.length === 0)
884
+ return "mcp=not-configured";
885
+ const descriptors = mcpStatuses.map((status) => {
886
+ if (status.command)
887
+ return `mcp=stdio:${status.command}`;
888
+ if (status.url)
889
+ return `mcp=sse:${status.url}`;
890
+ return "mcp=configured";
891
+ });
892
+ return descriptors.join(", ");
893
+ }
894
+ function ticketFlowOptions(ticketId, absPath, statusLabel, opts = {}, replacements = {}) {
895
+ const normalizedAbsPath = toPosixPath(absPath);
896
+ return {
897
+ ...ticketWorkflowOptions(opts, replacements),
898
+ flow: {
899
+ workspaceLabel: resolveTicketWorkspaceLabel(opts.cwd, normalizedAbsPath),
900
+ statusLabel,
901
+ ticketId,
902
+ absPath: normalizedAbsPath,
903
+ invocationCwd: opts.invocationCwd || opts.cwd || ""
904
+ }
905
+ };
906
+ }
907
+ function printTicketStartLine(ticketId, absPath, opts = {}) {
908
+ console.log(renderTicketFlowSurface("start", ticketFlowOptions(ticketId, absPath, FLOW_SURFACE_STATUS.start, opts)));
909
+ }
910
+ function printTicketEndLine(ticketId, absPath, opts = {}) {
911
+ console.log(renderTicketFlowSurface("end", ticketFlowOptions(ticketId, absPath, FLOW_SURFACE_STATUS.end, opts)));
912
+ }
913
+ function printTicketUpdateLine(ticketId, absPath, opts = {}) {
914
+ console.log(renderTicketFlowSurface("start", ticketFlowOptions(ticketId, absPath, FLOW_SURFACE_STATUS.adjust, opts)));
915
+ }
916
+ function printTicketPhaseLine(ticketId, absPath, phase, opts = {}) {
917
+ const normalizedPhase = normalizeTicketPhaseNumber(phase);
918
+ const statusLabel = normalizedPhase <= 1
919
+ ? FLOW_SURFACE_STATUS.adjust
920
+ : FLOW_SURFACE_STATUS.progressActive;
921
+ console.log(renderTicketFlowSurface(`phase${normalizedPhase}`, ticketFlowOptions(ticketId, absPath, statusLabel, opts, { PHASE: normalizedPhase })));
922
+ }
923
+ function printTicketStatusFlowLine(ticketId, absPath, phase, opts = {}) {
924
+ const normalizedPhase = normalizeTicketPhaseNumber(phase);
925
+ const flowState = normalizedPhase >= 4 ? "end" : `phase${normalizedPhase}`;
926
+ const runtimeStatus = String(opts.runtimeContext?.status || "").toLowerCase();
927
+ const isTerminal = normalizedPhase >= 4 || runtimeStatus === "archived" || runtimeStatus === "closed";
928
+ const statusLabel = isTerminal ? FLOW_SURFACE_STATUS.end : FLOW_SURFACE_STATUS.status;
929
+ console.log(renderTicketFlowSurface(flowState, ticketFlowOptions(ticketId, absPath, statusLabel, opts, { PHASE: normalizedPhase })));
930
+ }
931
+ function printTicketSelectionLine(ticketId, absPath, opts = {}) {
932
+ if (opts.pathOnly) {
933
+ console.log(absPath);
934
+ return;
935
+ }
936
+ console.log(formatTicketUpdateLine(ticketId, toPosixPath(absPath), opts.cwd, opts.invocationCwd));
937
+ }
938
+ // The approval surface tells the user to "review the plan", so the plan body
939
+ // (APC + Compact Plan, sans front matter and the DocMeta block) must be shown
940
+ // inline — otherwise nothing reviewable reaches the chat and the gate is blind.
941
+ function readApprovalPlanBody(absPath) {
942
+ try {
943
+ if (!absPath || !existsSync(absPath))
944
+ return "";
945
+ const { content } = parseFrontMatter(readFileSync(absPath, "utf8"));
946
+ return splitTicketDocMetaSection(content || "").body.trim();
947
+ }
948
+ catch {
949
+ return "";
950
+ }
951
+ }
952
+ function printPendingApprovalSurface(ticketId, absPath, opts = {}) {
953
+ const normalizedAbsPath = toPosixPath(absPath);
954
+ const flowLine = formatTicketUpdateLine(ticketId, normalizedAbsPath, opts.cwd, opts.invocationCwd);
955
+ const summary = String(opts.summary || opts.runtimeContext?.summary || "").trim();
956
+ const planBody = readApprovalPlanBody(absPath);
957
+ const wsName = opts.workspace || basename(String(opts.cwd || ""));
958
+ const lines = [
959
+ opts.includeFlowLine === false ? "" : flowLine,
960
+ summary ? `Summary: ${summary}` : "",
961
+ "DocMeta state: approval_required",
962
+ `Source: ${normalizedAbsPath}`,
963
+ "",
964
+ ...(planBody ? ["--- Plan under review ---", planBody, "--- End of plan ---", ""] : []),
965
+ "Required slots:",
966
+ "- phase1Plan",
967
+ "- userApproval",
968
+ "",
969
+ "Output: transition_blocked_until_approval",
970
+ "Action: review the plan above, then type `승인`.",
971
+ "",
972
+ ...workspacePreflightLines(opts.cwd),
973
+ "Adapter command after approval (run ONLY after the user approves in chat):",
974
+ "```bash",
975
+ `deuk-agent-flow ticket move --id ${ticketId} --workspace ${wsName} --phase 2 --approval approved --non-interactive`,
976
+ "```",
977
+ "",
978
+ "Not proceeding? Do NOT self-approve or force a phase jump — discard it instead:",
979
+ "```bash",
980
+ `deuk-agent-flow ticket discard --id ${ticketId} --workspace ${wsName} --non-interactive`,
981
+ "```"
982
+ ];
983
+ console.log(lines.filter(line => line !== "").join("\n"));
984
+ }
985
+ function printExecutionSurface(ticketId, absPath, opts = {}) {
986
+ const normalizedAbsPath = toPosixPath(absPath);
987
+ const phase = Number(opts.phase || opts.runtimeContext?.phase || 2);
988
+ const flowLine = formatTicketPhaseLine(ticketId, normalizedAbsPath, phase, opts.cwd, opts.invocationCwd);
989
+ if (opts.includeFlowLine !== false)
990
+ console.log(flowLine);
991
+ }
992
+ function printTicketUseNextSteps(found, foundDoc, opts = {}) {
993
+ const phase = Number(foundDoc.meta.phase || found.phase || 1);
994
+ const status = String(foundDoc.meta.status || found.status || "open").toLowerCase();
995
+ const needsApprovalFlow = phase <= 1 && status === "open";
996
+ const docsLanguage = foundDoc.meta.docsLanguage || opts.docsLanguage || "ko";
997
+ const runtimeContext = buildTicketRuntimeContext(found.id, foundDoc.absPath, foundDoc.meta, {
998
+ cwd: opts.cwd,
999
+ hostWorkspaceLabel: opts.hostWorkspaceContext?.breadcrumb,
1000
+ status,
1001
+ phase,
1002
+ summary: found.summary,
1003
+ nextAction: needsApprovalFlow ? "await-user-approve" : "continue-execution"
1004
+ });
1005
+ if (needsApprovalFlow) {
1006
+ printPendingApprovalSurface(found.id, foundDoc.absPath, { ...opts, docsLanguage, runtimeContext, summary: found.summary, includeFlowLine: false });
1007
+ return;
1008
+ }
1009
+ if (phase >= 2 && OPEN_TICKET_STATUSES.has(status)) {
1010
+ // printTicketSelectionLine already emitted the flow line for `use`; avoid a duplicate.
1011
+ printExecutionSurface(found.id, foundDoc.absPath, { ...opts, docsLanguage, runtimeContext, summary: found.summary, phase, status, includeFlowLine: false });
1012
+ return;
1013
+ }
1014
+ const pipelineOpts = { ...ticketWorkflowOptions({ ...opts, docsLanguage, runtimeContext }), docsLanguage };
1015
+ console.log(renderTicketDocMetaSurface("useCoreActions", pipelineOpts));
1016
+ }
1017
+ function getHandoffSummary(out) {
1018
+ const next = out.nextTicket ? `${out.nextTicket.id}:${out.nextTicket.status}` : "none";
1019
+ const blockers = out.reasons?.length ? out.reasons.join(",") : "none";
1020
+ const telemetry = out.telemetrySummary || "telemetry none";
1021
+ return `${out.current.id} | phase=${out.current.phase} | status=${out.current.status} | next=${next} | blockers=${blockers} | ${telemetry}`;
1022
+ }
1023
+ function buildTicketContentSection(ticketContent, docsLanguage) {
1024
+ const trimmed = String(ticketContent || "").trim();
1025
+ if (!trimmed)
1026
+ return "";
1027
+ const heading = docsLanguage === "ko" ? "## 맥락" : "## Context";
1028
+ return `${heading}\n\n${trimmed}\n`;
1029
+ }
1030
+ function readCliTextInput(cwd, inputPath, label) {
1031
+ const value = String(inputPath || "").trim();
1032
+ if (!value)
1033
+ return "";
1034
+ if (value === "-")
1035
+ return readFileSync(0, "utf8");
1036
+ const absPath = resolve(cwd, value);
1037
+ if (!existsSync(absPath))
1038
+ throw new Error(`${label}: file not found ${value}`);
1039
+ return readFileSync(absPath, "utf8");
1040
+ }
1041
+ function hydrateCreateTextInputs(opts) {
1042
+ const next = { ...opts };
1043
+ if (next.planBodyFile) {
1044
+ next.planBody = readCliTextInput(next.cwd, next.planBodyFile, "ticket create --plan-body-file");
1045
+ }
1046
+ if (next.contentFile) {
1047
+ next.content = readCliTextInput(next.cwd, next.contentFile, "ticket create --content-file");
1048
+ }
1049
+ return next;
1050
+ }
1051
+ function injectTicketContent(baseContent, ticketContent, docsLanguage) {
1052
+ const section = buildTicketContentSection(ticketContent, docsLanguage);
1053
+ if (!section)
1054
+ return baseContent;
1055
+ if (/^## Tasks\b/m.test(baseContent)) {
1056
+ return baseContent.replace(/^## Tasks\b/m, `${section}\n## Tasks`);
1057
+ }
1058
+ return `${String(baseContent || "").trimEnd()}\n\n${section}`;
1059
+ }
1060
+ function lintTicketWorkflowMarkdown(cwd, targets, context) {
1061
+ let uniqueTargets = Array.from(new Set((targets || []).filter(Boolean)));
1062
+ if (uniqueTargets.length === 0)
1063
+ return { errors: [], targets: [] };
1064
+ // Filter out archive files from lint validation for ticket create operations
1065
+ // Archive files' format issues should not block new ticket creation
1066
+ if (context && context.includes("ticket create")) {
1067
+ uniqueTargets = uniqueTargets.filter(target => {
1068
+ const normalized = String(target || "").replace(/\\/g, "/");
1069
+ return !normalized.includes("/archive/");
1070
+ });
1071
+ }
1072
+ if (uniqueTargets.length === 0)
1073
+ return { errors: [], targets: [] };
1074
+ for (const target of uniqueTargets) {
1075
+ normalizeMarkdownFile(target);
1076
+ }
1077
+ const result = lintMarkdownPaths(uniqueTargets, cwd);
1078
+ if (result.errors.length > 0) {
1079
+ const details = result.errors.map(err => `- ${err}`).join("\n");
1080
+ throw new Error(`[VALIDATION FAILED] ${context}: markdown lint failed\n${details}`);
1081
+ }
1082
+ return result;
1083
+ }
1084
+ function runTicketWorkflowQualityGate(cwd, { ticketAbsPath, extraTargets = [], context }) {
1085
+ const lintTargets = collectTicketWorkflowMarkdownTargets(cwd, ticketAbsPath, extraTargets);
1086
+ lintTicketWorkflowMarkdown(cwd, lintTargets, context);
1087
+ if (!existsSync(makePath(cwd, "core-rules", "AGENTS.md")))
1088
+ return;
1089
+ const workflowRelTargets = lintTargets.map(target => toWorkspaceRelativePath(cwd, target));
1090
+ if (!shouldRunWorkflowRulesAudit(workflowRelTargets))
1091
+ return;
1092
+ const result = auditRules(cwd);
1093
+ if (result.ok)
1094
+ return;
1095
+ const details = result.violations.map(violation => `- ${violation.code}: ${violation.message}`).join("\n");
1096
+ throw new Error(`[VALIDATION FAILED] ${context}: rules audit failed\n${details}`);
1097
+ }
1098
+ function looksLikeTicketMarkdownPath(value) {
1099
+ const raw = String(value || "");
1100
+ return /\.md$/i.test(raw) && /[/\\]/.test(raw);
1101
+ }
1102
+ function findTicketRepoRootFromPath(absPath) {
1103
+ let dir = dirname(absPath);
1104
+ while (true) {
1105
+ if (basename(dir) === TICKET_SUBDIR && basename(dirname(dir)) === DEUK_ROOT_DIR) {
1106
+ return dirname(dirname(dir));
1107
+ }
1108
+ const parent = dirname(dir);
1109
+ if (parent === dir)
1110
+ return null;
1111
+ dir = parent;
1112
+ }
1113
+ }
1114
+ function applyTicketPathContext(opts = {}) {
1115
+ const rawTicketPath = opts.ticketPath || (looksLikeTicketMarkdownPath(opts.ticketId) ? opts.ticketId : "");
1116
+ if (!rawTicketPath)
1117
+ return opts;
1118
+ const absPath = resolve(opts.cwd, rawTicketPath);
1119
+ if (!existsSync(absPath)) {
1120
+ throw new Error(`ticket path not found: ${rawTicketPath}`);
1121
+ }
1122
+ const ticketRepoRoot = findTicketRepoRootFromPath(absPath);
1123
+ if (!ticketRepoRoot) {
1124
+ throw new Error(`ticket path is not inside ${DEUK_ROOT_DIR}/${TICKET_SUBDIR}: ${rawTicketPath}`);
1125
+ }
1126
+ const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
1127
+ const ticketSelector = meta.id || basename(absPath).replace(/\.md$/i, "");
1128
+ if (!ticketSelector) {
1129
+ throw new Error(`ticket path has no id frontmatter: ${rawTicketPath}`);
1130
+ }
1131
+ opts.cwd = ticketRepoRoot;
1132
+ opts.ticketId = ticketSelector;
1133
+ opts.ticketPath = absPath;
1134
+ return opts;
1135
+ }
1136
+ function applyTicketContext(opts = {}) {
1137
+ applyTicketPathContext(opts);
1138
+ if (!opts.ticketId && opts.title && !opts.summary && !opts.planBody && !opts.planBodyFile) {
1139
+ opts.ticketId = opts.title;
1140
+ }
1141
+ return opts;
1142
+ }
1143
+ function formatWorkspaceCandidates(matches = []) {
1144
+ return matches.map(workspace => `${workspace.id} @ ${workspace.path}`).join(", ");
1145
+ }
1146
+ function isAgentFlowDomainPrompt(promptText = "") {
1147
+ const text = String(promptText || "").toLowerCase();
1148
+ if (!text.trim())
1149
+ return false;
1150
+ return [
1151
+ /deuk[-_\s]*agent[-_\s]*flow/,
1152
+ /\bticket[-_\s]*workflow\b/,
1153
+ /\brules\s+ticket\b/,
1154
+ /\bagents\.md\b/,
1155
+ /\bcore[-_\s]*rules?\b/,
1156
+ /\bticket[-_\s]*(?:routing|target|workspace|create|workflow|state|transition)\b/,
1157
+ /\bworkspace\/cwd\b/,
1158
+ /\bcwd\s+(?:fallback|routing|workspace|target)\b/,
1159
+ /\bworkspace\s+(?:routing|resolution|target|cwd|ticket|misissue|misroute)\b/
1160
+ ].some(pattern => pattern.test(text));
1161
+ }
1162
+ function findAgentFlowWorkspace(candidates = {}) {
1163
+ return (candidates.workspaces || []).find(workspace => {
1164
+ const id = String(workspace.id || "").toLowerCase();
1165
+ const pathBase = basename(workspace.path || "").toLowerCase();
1166
+ return id === "deukagentflow" || pathBase === "deukagentflow";
1167
+ }) || null;
1168
+ }
1169
+ function applyTicketWorkspacePromptDispatch(opts = {}, promptText = "") {
1170
+ if (opts.workspace)
1171
+ return opts;
1172
+ const candidates = loadWorkspaceCandidates(opts.invocationCwd || opts.cwd);
1173
+ if (!candidates?.workspaces?.length)
1174
+ return opts;
1175
+ const currentRoot = resolve(opts.cwd);
1176
+ const strictCandidates = {
1177
+ ...candidates,
1178
+ workspaces: candidates.workspaces.map(workspace => ({
1179
+ ...workspace,
1180
+ aliases: buildPromptWorkspaceAliases(workspace)
1181
+ }))
1182
+ };
1183
+ const matches = findPromptWorkspaceMatches(promptText, strictCandidates)
1184
+ .filter(workspace => resolve(workspace.path) !== currentRoot);
1185
+ if (matches.length === 0) {
1186
+ const flowWorkspace = findAgentFlowWorkspace(candidates);
1187
+ if (flowWorkspace && resolve(flowWorkspace.path) !== currentRoot && isAgentFlowDomainPrompt(promptText)) {
1188
+ opts.cwd = flowWorkspace.path;
1189
+ opts.workspace = flowWorkspace.id;
1190
+ }
1191
+ return opts;
1192
+ }
1193
+ if (matches.length > 1) {
1194
+ throw new Error(`ticket workspace target is ambiguous from prompt: ${formatWorkspaceCandidates(matches)}. Re-run with --workspace <name>.`);
1195
+ }
1196
+ opts.cwd = matches[0].path;
1197
+ opts.workspace = matches[0].id;
1198
+ return opts;
1199
+ }
1200
+ function applyTicketWorkspaceDispatch(opts = {}) {
1201
+ const explicitQuery = opts.workspace || "";
1202
+ // #652: registry-only. The workspace is resolved from the home registry candidates
1203
+ // (or the session cookie), NEVER from a cwd-basename match — that old fallback let
1204
+ // any cwd whose folder name resembled the query masquerade as a registered
1205
+ // workspace and was a ghost factory.
1206
+ const candidates = loadWorkspaceCandidates("", { homeDir: opts.homeDir });
1207
+ // No explicit --workspace: try the session cookie (registry-backed). If that also
1208
+ // resolves nothing, leave opts.cwd as-is — callers that genuinely need a workspace
1209
+ // will fail later with a clear "workspace not found", never silently inferring one
1210
+ // from cwd.
1211
+ if (!explicitQuery) {
1212
+ if (opts.sessionId && candidates && candidates.workspaces.length > 0) {
1213
+ const cookieRes = resolveWorkspaceTarget("", "", { candidates, sessionId: opts.sessionId, homeDir: opts.homeDir });
1214
+ if (cookieRes?.target) {
1215
+ opts.cwd = cookieRes.target.path;
1216
+ opts.workspace = cookieRes.target.id;
1217
+ }
1218
+ }
1219
+ return opts;
1220
+ }
1221
+ if (!candidates || candidates.workspaces.length === 0) {
1222
+ throw new Error(`ticket workspace target not found: ${explicitQuery} (no workspaces registered — run \`deuk-agent-flow init\` in the workspace first)`);
1223
+ }
1224
+ const resolution = resolveWorkspaceTarget("", explicitQuery, { candidates, sessionId: opts.sessionId, homeDir: opts.homeDir });
1225
+ if (resolution?.reason === "ambiguous") {
1226
+ throw new Error(`ticket workspace target is ambiguous: ${formatWorkspaceCandidates(resolution.matches)}`);
1227
+ }
1228
+ if (resolution?.target) {
1229
+ opts.cwd = resolution.target.path;
1230
+ opts.workspace = resolution.target.id;
1231
+ return opts;
1232
+ }
1233
+ throw new Error(`ticket workspace target not found: ${explicitQuery}`);
1234
+ }
1235
+ function applyTicketRootContext(opts = {}, options = {}) {
1236
+ if (!opts.hostWorkspaceContext) {
1237
+ opts.hostWorkspaceContext = resolveWorkspaceContext(opts.cwd);
1238
+ }
1239
+ applyTicketWorkspaceDispatch(opts);
1240
+ const root = resolveConsumerTicketRoot(opts.cwd, options);
1241
+ if (root)
1242
+ opts.cwd = root;
1243
+ opts.workspaceContext = resolveWorkspaceContext(opts.cwd);
1244
+ return opts;
1245
+ }
1246
+ function normalizeMarkdownLintTargets(cwd, targets = []) {
1247
+ return Array.from(new Set((targets || [])
1248
+ .filter(Boolean)
1249
+ .map(target => resolve(cwd, target))));
1250
+ }
1251
+ function collectTicketWorkflowMarkdownTargets(cwd, ticketAbsPath, extraTargets = []) {
1252
+ return normalizeMarkdownLintTargets(cwd, [
1253
+ ticketAbsPath,
1254
+ ...(extraTargets || [])
1255
+ ]);
1256
+ }
1257
+ function toWorkspaceRelativePath(cwd, targetPath) {
1258
+ const normalizedTarget = String(targetPath || "").trim();
1259
+ if (!normalizedTarget)
1260
+ return "";
1261
+ const absTarget = normalizedTarget.startsWith("/")
1262
+ ? resolve(normalizedTarget)
1263
+ : resolve(cwd, normalizedTarget);
1264
+ return toPosixPath(relative(cwd, absTarget)).replace(/^\.\//, "");
1265
+ }
1266
+ function shouldRunWorkflowRulesAudit(changedFiles = []) {
1267
+ return (changedFiles || []).some(relPath => {
1268
+ const normalized = String(relPath || "").replace(/\\/g, "/");
1269
+ if (!normalized)
1270
+ return false;
1271
+ return normalized === "core-rules/AGENTS.md"
1272
+ || normalized === "PROJECT_RULE.md"
1273
+ || normalized === "AGENTS.md"
1274
+ || normalized === ".codex/AGENTS.md"
1275
+ || normalized.startsWith(".claude/rules/")
1276
+ || normalized.startsWith(".aiassistant/rules/")
1277
+ || normalized.startsWith(".windsurf/rules/")
1278
+ || normalized.startsWith(".cursor/rules/")
1279
+ || normalized.startsWith("templates/rules.d/")
1280
+ || normalized === "templates/PROJECT_RULE.md"
1281
+ || normalized === "scripts/out/scripts/lint-rules.js";
1282
+ });
1283
+ }
1284
+ function restoreTicketIndexSnapshot(cwd, snapshot, opts = {}) {
1285
+ if (opts.dryRun)
1286
+ return;
1287
+ writeTicketIndexJson(cwd, snapshot, opts);
1288
+ }
1289
+ function rollbackTicketWorkflowArtifacts(cwd, previousIndex, previousBody, absPath, opts = {}) {
1290
+ if (opts.dryRun)
1291
+ return;
1292
+ if (previousBody !== undefined && absPath) {
1293
+ writeTicketMarkdownFile(absPath, previousBody);
1294
+ }
1295
+ if (previousIndex) {
1296
+ restoreTicketIndexSnapshot(cwd, previousIndex, opts);
1297
+ }
1298
+ }
1299
+ function getPhase1IncompleteReasonsFromBody(body) {
1300
+ let parsed = { meta: {}, content: "" };
1301
+ try {
1302
+ parsed = parseFrontMatter(body);
1303
+ }
1304
+ catch (err) {
1305
+ return ["frontmatter_parse_failed"];
1306
+ }
1307
+ if (parsed.parseError) {
1308
+ return ["frontmatter_parse_failed"];
1309
+ }
1310
+ return getPhase1PlanBodyReasons(normalizePhase1PlanBodyHeadings(parsed.content || ""));
1311
+ }
1312
+ function getPhase1IncompleteReasons(cwd, absPath) {
1313
+ if (!existsSync(absPath))
1314
+ return ["ticket_file_missing"];
1315
+ return getPhase1IncompleteReasonsFromBody(readFileSync(absPath, "utf8"));
1316
+ }
1317
+ function collectTicketTransitionValidationDocMeta(cwd, absPath, meta = {}, content = "", opts = {}, targetState = "") {
1318
+ const phase1Reasons = content
1319
+ ? getPhase1PlanBodyReasons(normalizePhase1PlanBodyHeadings(content || ""))
1320
+ : getPhase1IncompleteReasons(cwd, absPath);
1321
+ const closeReasons = getCloseWorkflowReasons(meta, content);
1322
+ // phase2→phase3: executionEvidence is satisfied by phase2 execution slots or plan content.
1323
+ // Other transitions (phase3→end, phase4→end) still require close workflow sections.
1324
+ const executionEvidenceOk = targetState === "phase3"
1325
+ ? hasPhase2ExecutionEvidence(content)
1326
+ : closeReasons.length === 0;
1327
+ const slotValues = {
1328
+ phase1Plan: phase1Reasons.length === 0,
1329
+ userApproval: hasExplicitExecutionApproval(opts),
1330
+ executionEvidence: executionEvidenceOk,
1331
+ verificationEvidence: closeReasons.length === 0 || Boolean(opts.reason || opts.verificationEvidence),
1332
+ debuggingDecision: Boolean(opts.debuggingDecision || opts.reason || targetState !== "phase2"),
1333
+ completionEvidence: closeReasons.length === 0 || Boolean(opts.reason || opts.completionEvidence),
1334
+ reopenDecision: Boolean(opts.reopenDecision || opts.reason || targetState !== "phase1")
1335
+ };
1336
+ const executionEvidenceErrors = targetState === "phase3" && !executionEvidenceOk
1337
+ ? ["phase2_execution_slots_empty"]
1338
+ : closeReasons;
1339
+ const slotSourceMap = {
1340
+ phase1Plan: { source: toRepoRelativePath(cwd, absPath), errors: phase1Reasons },
1341
+ userApproval: { source: "--approval approved | --workflow execute" },
1342
+ executionEvidence: { source: "ticket workflow sections (phase2 execution slots or Analysis)", errors: executionEvidenceErrors },
1343
+ verificationEvidence: { source: "ticket workflow sections", errors: closeReasons },
1344
+ debuggingDecision: { source: "--reason | debugging decision" },
1345
+ completionEvidence: { source: "ticket workflow sections", errors: closeReasons },
1346
+ reopenDecision: { source: "--reason | reopen decision" }
1347
+ };
1348
+ const currentState = `phase${normalizeTicketPhaseNumber(meta.phase || 1)}`;
1349
+ const requiredSlots = targetState
1350
+ ? getTicketWorkflowTransitionEvidenceKeys(currentState, targetState)
1351
+ : Object.keys(slotValues);
1352
+ return buildTransitionValidationDocMeta({
1353
+ ticketId: meta.ticketId || meta.id || "",
1354
+ phase: normalizeTicketPhaseNumber(meta.phase || 1),
1355
+ status: String(meta.status || "open"),
1356
+ currentState,
1357
+ targetState,
1358
+ requiredSlots,
1359
+ slotValues,
1360
+ slotSourceMap
1361
+ });
1362
+ }
1363
+ function formatTicketWorkflowGateFailure(cwd, absPath, entry, meta = {}, content = "", opts = {}, targetState = "phase2", reasons = []) {
1364
+ const ticketId = entry?.id || meta.ticketId || meta.id || "<ticket-id>";
1365
+ const docmeta = collectTicketTransitionValidationDocMeta(cwd, absPath, meta, content, opts, targetState);
1366
+ return renderTicketWorkflowGateFailure({ ticketId, workspace: basename(String(cwd || "")), docmeta, reasons });
1367
+ }
1368
+ function getTicketWorkflowTransitionEvidence(cwd, absPath, meta = {}, content = "", opts = {}, targetState = "") {
1369
+ return collectTicketTransitionValidationDocMeta(cwd, absPath, meta, content, opts, targetState).slots;
1370
+ }
1371
+ function resolveTicketWorkflowCommandTransition(cwd, entry, absPath, meta, content, opts = {}, transitionOpts = {}) {
1372
+ assertTicketWorkflowAction(meta, {
1373
+ fallbackStatus: entry.status || "open",
1374
+ action: transitionOpts.action
1375
+ });
1376
+ const preview = resolveTicketWorkflowTransition(meta, {
1377
+ fallbackStatus: entry.status || "open",
1378
+ ...transitionOpts
1379
+ });
1380
+ return assertTicketWorkflowTransition(meta, {
1381
+ fallbackStatus: entry.status || "open",
1382
+ ...transitionOpts,
1383
+ evidence: getTicketWorkflowTransitionEvidence(cwd, absPath, meta, content, opts, preview.to)
1384
+ });
1385
+ }
1386
+ function hasExplicitExecutionApproval(opts = {}) {
1387
+ if (!Object.prototype.hasOwnProperty.call(opts, "workflowMode")
1388
+ && !Object.prototype.hasOwnProperty.call(opts, "workflow")
1389
+ && !Object.prototype.hasOwnProperty.call(opts, "approval")
1390
+ && !Object.prototype.hasOwnProperty.call(opts, "approvalState")) {
1391
+ return false;
1392
+ }
1393
+ return normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approval ?? opts.approvalState) === WORKFLOW_MODE_EXECUTE;
1394
+ }
1395
+ function getTicketWorkflowProvenanceReasons(entry, meta = {}) {
1396
+ const reasons = [];
1397
+ if (!entry || String(entry.status || "").toLowerCase() === "archived")
1398
+ return reasons;
1399
+ if (!isExecutionTicketStatus(entry.status || meta.status || "open"))
1400
+ return reasons;
1401
+ const workflowSource = String(meta.workflowSource || meta.ticketWorkflowSource || "").trim();
1402
+ if (workflowSource !== "ticket-create") {
1403
+ reasons.push("manual_ticket_workflow_provenance_missing");
1404
+ }
1405
+ return reasons;
1406
+ }
1407
+ function assertTicketWorkflowProvenance(entry, meta = {}) {
1408
+ const reasons = getTicketWorkflowProvenanceReasons(entry, meta);
1409
+ if (reasons.length === 0)
1410
+ return;
1411
+ throw new Error([
1412
+ `[VALIDATION FAILED] Ticket ${entry?.id || "unknown"} cannot be used as an execution ticket: ${reasons.join(", ")}.`,
1413
+ "This ticket file does not carry CLI creation provenance.",
1414
+ "Do not create or repair tickets by writing .deuk/tickets/**/*.md directly.",
1415
+ "Use: npx deuk-agent-flow ticket create --title <title> --summary <summary> --plan-body \"<filled body>\" --non-interactive"
1416
+ ].join("\n"));
1417
+ }
1418
+ function updatePreviousTicketRef(cwd, prevTicketEntry, ticketId) {
1419
+ if (!prevTicketEntry)
1420
+ return;
1421
+ const prevAbsPath = makePath(cwd, prevTicketEntry.path);
1422
+ if (!existsSync(prevAbsPath))
1423
+ return;
1424
+ let prevContent = readFileSync(prevAbsPath, "utf8");
1425
+ prevContent = prevContent.replace(/^---\n([\s\S]*?)\n---/, (match, fm) => {
1426
+ if (!fm.includes('nextTicket:')) {
1427
+ return `---\n${fm.trim()}\nnextTicket: ${ticketId}\n---`;
1428
+ }
1429
+ return match;
1430
+ });
1431
+ writeTicketMarkdownFile(prevAbsPath, prevContent);
1432
+ return prevTicketEntry.id;
1433
+ }
1434
+ function resolveMainTicketForSubTicket(indexJson, opts, prevTicketEntry) {
1435
+ if (opts.parent)
1436
+ return String(opts.parent);
1437
+ if (prevTicketEntry?.id || prevTicketEntry?.ticketId)
1438
+ return prevTicketEntry.id;
1439
+ const entries = [...(indexJson.entries || [])]
1440
+ .filter(entry => normalizeTicketGroup(entry.group, "sub") === "main")
1441
+ .filter(entry => entry.status === "open" || entry.status === "active")
1442
+ .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
1443
+ return entries[0]?.id || null;
1444
+ }
1445
+ function archivePartitionForEntry(entry, now = new Date()) {
1446
+ const storedYearMonth = String(entry?.archiveYearMonth || "");
1447
+ if (/^\d{4}-\d{2}$/.test(storedYearMonth)) {
1448
+ return { yearMonth: storedYearMonth };
1449
+ }
1450
+ const source = String(entry?.createdAt || "");
1451
+ const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
1452
+ if (match)
1453
+ return { yearMonth: `${match[1]}-${match[2]}` };
1454
+ const iso = now.toISOString();
1455
+ return { yearMonth: iso.slice(0, 7) };
1456
+ }
1457
+ function getArchiveDestination(ticketDir, entry, fileName) {
1458
+ const partition = archivePartitionForEntry(entry);
1459
+ const archiveDir = makePath(ticketDir, "archive", entry.group || "sub", partition.yearMonth);
1460
+ return {
1461
+ archiveDir,
1462
+ archiveYearMonth: partition.yearMonth,
1463
+ newAbsPath: makePath(archiveDir, fileName)
1464
+ };
1465
+ }
1466
+ function archiveStorageFromPath(ticketDir, absPath, entry) {
1467
+ const parts = toPosixPath(relative(ticketDir, absPath)).split("/");
1468
+ const archiveIdx = parts.indexOf("archive");
1469
+ if (archiveIdx < 0)
1470
+ return archivePartitionForEntry(entry);
1471
+ return {
1472
+ archiveYearMonth: parts[archiveIdx + 2] || archivePartitionForEntry(entry).yearMonth
1473
+ };
1474
+ }
1475
+ function findExistingArchivedTicketPath(ticketDir, entry, fileName) {
1476
+ const expected = getArchiveDestination(ticketDir, entry, fileName).newAbsPath;
1477
+ if (existsSync(expected))
1478
+ return expected;
1479
+ const archiveRoot = makePath(ticketDir, "archive", entry.group || "sub");
1480
+ if (!existsSync(archiveRoot))
1481
+ return null;
1482
+ const stack = [archiveRoot];
1483
+ while (stack.length > 0) {
1484
+ const dir = stack.pop();
1485
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
1486
+ const abs = makePath(dir, item.name);
1487
+ if (item.isDirectory()) {
1488
+ stack.push(abs);
1489
+ }
1490
+ else if (item.isFile() && item.name === fileName) {
1491
+ return abs;
1492
+ }
1493
+ }
1494
+ }
1495
+ return null;
1496
+ }
1497
+ function isOpenTicketEntry(entry) {
1498
+ const phase = Number(entry?.phase || 1);
1499
+ if (phase >= 4 || entry?.archiveYearMonth)
1500
+ return false;
1501
+ return OPEN_TICKET_STATUSES.has(String(entry?.status || "open"));
1502
+ }
1503
+ function isAutoArchivableDoneEntry(entry) {
1504
+ const status = String(entry?.status || "").toLowerCase();
1505
+ const phase = Number(entry?.phase || 1);
1506
+ return AUTO_ARCHIVE_DONE_STATUSES.has(status) || phase >= 4 || Boolean(entry?.archiveYearMonth);
1507
+ }
1508
+ function latestTicketByStatus(entries, statuses) {
1509
+ const statusSet = new Set(statuses);
1510
+ return [...(entries || [])]
1511
+ .filter(e => statusSet.has(String(e.status || "").toLowerCase()))
1512
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0] || null;
1513
+ }
1514
+ function formatTicketChoice(entry, activeTicketId = null) {
1515
+ // The single focused ticket (activeTicketId) renders as ACTIVE; all other live
1516
+ // tickets render by their lifecycle status (OPEN). "active" is no longer a status.
1517
+ const rawStatus = String(entry.status || "open").toLowerCase();
1518
+ const isFocus = activeTicketId && entry.id === activeTicketId && rawStatus !== "archived" && Number(entry.phase || 1) < 4;
1519
+ const status = isFocus ? "ACTIVE" : rawStatus.toUpperCase();
1520
+ const phase = `ph${Number(entry.phase || 1)}`;
1521
+ const previewSource = String(entry.summary || entry.title || entry.id || entry.id || "")
1522
+ .replace(/(\n|\\n)+/g, " ")
1523
+ .replace(/\s+/g, " ")
1524
+ .trim();
1525
+ const maxPreviewLength = 140;
1526
+ const preview = previewSource.length > maxPreviewLength ? `${previewSource.slice(0, maxPreviewLength - 3)}...` : previewSource;
1527
+ return `${entry.id} | ${status} | ${phase} | ${preview}`;
1528
+ }
1529
+ function buildUseFallbackCandidates(indexJson, opts = {}) {
1530
+ const entries = filterTicketEntries(indexJson.entries, opts);
1531
+ const lastClosed = latestTicketByStatus(entries, ["closed"]);
1532
+ const openRows = entries
1533
+ .filter(e => OPEN_TICKET_STATUSES.has(String(e.status || "open")))
1534
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")));
1535
+ const seen = new Set();
1536
+ return [lastClosed, ...openRows]
1537
+ .filter(Boolean)
1538
+ .filter(entry => {
1539
+ if (seen.has(entry.id))
1540
+ return false;
1541
+ seen.add(entry.id);
1542
+ return true;
1543
+ });
1544
+ }
1545
+ function buildUseNoMatchError(ticketId, candidates) {
1546
+ const lines = [
1547
+ `No matching ticket found for "${ticketId || ""}".`,
1548
+ "Last closed ticket and open tickets:"
1549
+ ];
1550
+ if (candidates.length === 0) {
1551
+ lines.push(" - none");
1552
+ }
1553
+ else {
1554
+ for (const entry of candidates.slice(0, 20)) {
1555
+ lines.push(` - ${formatTicketChoice(entry)}`);
1556
+ }
1557
+ }
1558
+ lines.push("");
1559
+ lines.push("Choose one explicitly:");
1560
+ lines.push(" npx deuk-agent-flow ticket use --id <ticket-id> --non-interactive");
1561
+ return lines.join("\n");
1562
+ }
1563
+ function oldestFirst(a, b) {
1564
+ return String(a.createdAt || "").localeCompare(String(b.createdAt || ""));
1565
+ }
1566
+ function selectOpenLimitCandidates(indexJson) {
1567
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
1568
+ const overflow = openRows.length - MAX_OPEN_TICKETS;
1569
+ if (overflow <= 0)
1570
+ return [];
1571
+ const currentActiveId = indexJson.activeTicketId;
1572
+ const openCandidates = openRows
1573
+ .filter(e => e.status === "open" && e.id !== currentActiveId)
1574
+ .sort(oldestFirst);
1575
+ const activeCandidates = openRows
1576
+ .filter(e => e.status === "active" && e.id !== currentActiveId)
1577
+ .sort(oldestFirst);
1578
+ const lastResort = openRows
1579
+ .filter(e => e.id === currentActiveId)
1580
+ .sort(oldestFirst);
1581
+ return [...openCandidates, ...activeCandidates, ...lastResort].slice(0, overflow);
1582
+ }
1583
+ function buildOpenTicketLimitError(indexJson) {
1584
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
1585
+ if (openRows.length <= MAX_OPEN_TICKETS)
1586
+ return null;
1587
+ const candidates = selectOpenLimitCandidates(indexJson);
1588
+ const lines = [
1589
+ `flow:[OPEN TICKET LIMIT] Open tickets: ${openRows.length}/${MAX_OPEN_TICKETS}.`,
1590
+ "Ticket creation was cancelled so open tickets do not exceed the limit.",
1591
+ "Review the active ticket list, decide what can be archived, then create the ticket again.",
1592
+ "",
1593
+ "Commands:",
1594
+ " npx deuk-agent-flow ticket list --active --non-interactive",
1595
+ " npx deuk-agent-flow ticket archive --id <ticket-id> --non-interactive",
1596
+ "",
1597
+ "Oldest archive candidates:"
1598
+ ];
1599
+ for (const entry of candidates.slice(0, 10)) {
1600
+ const title = String(entry.title || entry.id || "").replace(/(\n|\\n)+/g, " ").slice(0, 80);
1601
+ lines.push(` - ${entry.id} | ${entry.status || "open"} | ${entry.createdAt || "-"} | ${title}`);
1602
+ }
1603
+ return lines.join("\n");
1604
+ }
1605
+ function titleKeysForEntry(entry = {}) {
1606
+ const keys = [entry.id];
1607
+ try {
1608
+ if (entry.title)
1609
+ keys.push(toSlug(entry.title));
1610
+ }
1611
+ catch {
1612
+ // Non-ASCII or otherwise unsluggable titles are not duplicate keys.
1613
+ }
1614
+ return keys
1615
+ .map(value => String(value || "").toLowerCase())
1616
+ .filter(Boolean);
1617
+ }
1618
+ function findReusableCompletedTicket(indexJson, titleSlug, opts = {}) {
1619
+ const key = String(titleSlug || "").toLowerCase();
1620
+ if (!key)
1621
+ return null;
1622
+ return filterTicketEntries(indexJson.entries, opts)
1623
+ .filter(entry => !OPEN_TICKET_STATUSES.has(String(entry.status || "open").toLowerCase()))
1624
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))
1625
+ .find(entry => titleKeysForEntry(entry).includes(key)) || null;
1626
+ }
1627
+ function buildReusableTicketCreateError(entry, titleSlug) {
1628
+ const id = entry?.id || titleSlug;
1629
+ const status = entry?.status || "closed";
1630
+ return [
1631
+ `[DUPLICATE TICKET BLOCKED] A ${status} ticket already matches "${titleSlug}".`,
1632
+ `Existing ticket: ${id}`,
1633
+ "Do not guess .deuk/tickets/sub paths for closed or archived tickets.",
1634
+ `Use: deuk-agent-flow ticket status --id ${id} --status-detail --non-interactive`,
1635
+ `Or: deuk-agent-flow ticket use --id ${id} --non-interactive`,
1636
+ "Create a new ticket only when the scope is genuinely different; use a distinct title for that scope."
1637
+ ].join("\n");
1638
+ }
1639
+ function normalizeArchivedTicketMeta(found, meta = {}) {
1640
+ const next = { ...meta };
1641
+ if (!next.status) {
1642
+ next.status = found?.status || "closed";
1643
+ }
1644
+ if (!next.phase) {
1645
+ next.phase = found?.phase || 4;
1646
+ }
1647
+ if (!next.summary) {
1648
+ next.summary = found?.summary || found?.title || found?.id || "archived ticket";
1649
+ }
1650
+ if (!next.priority) {
1651
+ next.priority = "P3";
1652
+ }
1653
+ if (!next.tags || (Array.isArray(next.tags) && next.tags.length === 0) || next.tags === "") {
1654
+ next.tags = ["archived"];
1655
+ }
1656
+ if (!next.id && found?.id)
1657
+ next.id = found.id;
1658
+ if (!next.title && found?.title)
1659
+ next.title = found.title;
1660
+ return next;
1661
+ }
1662
+ export function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {} }) {
1663
+ const absPath = resolveTicketEntryOrComputedPath(cwd, found);
1664
+ const fileName = (found.path || computeTicketPath(found)).split(/[/\\]/).pop();
1665
+ const resolvedRelPath = found.path || computeTicketPath(found);
1666
+ if (!existsSync(absPath)) {
1667
+ const archivedAbsPath = findExistingArchivedTicketPath(ticketDir, found, fileName);
1668
+ if (archivedAbsPath) {
1669
+ const storage = archiveStorageFromPath(ticketDir, archivedAbsPath, found);
1670
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1671
+ if (entryIdx >= 0) {
1672
+ indexJson.entries[entryIdx].fileName = fileName;
1673
+ indexJson.entries[entryIdx].status = "archived";
1674
+ indexJson.entries[entryIdx].archiveYearMonth = storage.archiveYearMonth;
1675
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1676
+ }
1677
+ const archivedRelativePath = toRepoRelativePath(cwd, archivedAbsPath);
1678
+ if (!isCompactTicketOutput(opts)) {
1679
+ console.warn("ticket archive: repaired already archived ticket " + archivedRelativePath);
1680
+ }
1681
+ return { id: found.id, path: archivedRelativePath, repaired: true };
1682
+ }
1683
+ if (String(found.status || "").toLowerCase() === "closed" && found.archiveYearMonth) {
1684
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1685
+ if (entryIdx >= 0) {
1686
+ indexJson.entries[entryIdx].fileName = fileName;
1687
+ indexJson.entries[entryIdx].status = "archived";
1688
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1689
+ }
1690
+ const archivedRelativePath = computeTicketPath({
1691
+ ...found,
1692
+ fileName,
1693
+ status: "archived"
1694
+ });
1695
+ if (!isCompactTicketOutput(opts)) {
1696
+ console.warn("ticket archive: normalized stale closed ticket metadata " + archivedRelativePath);
1697
+ }
1698
+ return { id: found.id, path: archivedRelativePath, normalized: true };
1699
+ }
1700
+ throw new Error("ticket archive: file not found " + resolvedRelPath);
1701
+ }
1702
+ const originalBody = readFileSync(absPath, "utf8");
1703
+ const parsedArchive = parseFrontMatter(originalBody);
1704
+ const archiveDocMeta = splitTicketDocMetaSection(parsedArchive.content || "");
1705
+ const archiveMeta = parsedArchive.parseError
1706
+ ? null
1707
+ : normalizeArchivedTicketMeta(found, parsedArchive.meta || {});
1708
+ const moveToArchiveStorage = Boolean(opts.moveFiles);
1709
+ const archiveDestination = moveToArchiveStorage ? getArchiveDestination(ticketDir, found, fileName) : null;
1710
+ const targetAbsPath = archiveDestination?.newAbsPath || absPath;
1711
+ if (moveToArchiveStorage && existsSync(targetAbsPath)) {
1712
+ throw new Error("ticket archive: destination already exists " + toRepoRelativePath(cwd, targetAbsPath));
1713
+ }
1714
+ if (moveToArchiveStorage && !opts.dryRun)
1715
+ mkdirSync(archiveDestination.archiveDir, { recursive: true });
1716
+ const archiveBody = archiveMeta ? archiveDocMeta.body : originalBody;
1717
+ if (opts.dryRun) {
1718
+ if (!isCompactTicketOutput(opts)) {
1719
+ const targetRelPath = moveToArchiveStorage ? toRepoRelativePath(cwd, targetAbsPath) : toRepoRelativePath(cwd, absPath);
1720
+ console.log(`ticket archive: would ${moveToArchiveStorage ? "move" : "mark archived in place"} ${toRepoRelativePath(cwd, absPath)}${moveToArchiveStorage ? ` to ${targetRelPath}` : ""}`);
1721
+ }
1722
+ return { dryRun: true };
1723
+ }
1724
+ const finalArchiveBody = String(archiveBody || "").trimEnd();
1725
+ if (archiveMeta) {
1726
+ archiveMeta.status = "archived";
1727
+ archiveMeta.phase = 4;
1728
+ writeTicketDocument(targetAbsPath, { meta: archiveMeta, content: finalArchiveBody, docmeta: archiveDocMeta.docmeta });
1729
+ }
1730
+ else {
1731
+ writeTicketMarkdownFile(targetAbsPath, originalBody);
1732
+ }
1733
+ if (moveToArchiveStorage)
1734
+ rmSync(absPath);
1735
+ try {
1736
+ if (!opts.skipWorkflowQualityGate) {
1737
+ runTicketWorkflowQualityGate(cwd, {
1738
+ ticketAbsPath: targetAbsPath,
1739
+ context: `ticket archive ${found.id}`
1740
+ });
1741
+ }
1742
+ }
1743
+ catch (err) {
1744
+ if (moveToArchiveStorage)
1745
+ rmSync(targetAbsPath, { force: true });
1746
+ writeTicketMarkdownFile(absPath, originalBody);
1747
+ throw err;
1748
+ }
1749
+ if (!opts.skipProjectMemoryUpdate) {
1750
+ try {
1751
+ const body = originalBody !== null ? originalBody : readFileSync(absPath, "utf8");
1752
+ const { meta, content: bodyContent } = parseFrontMatter(body);
1753
+ const ticketSections = extractMarkdownSections(bodyContent, ["Direction", "Improvement Direction", "Design Decisions", "Completion Report"]);
1754
+ updateProjectMemoryFromTicket(cwd, found.id, meta, ticketSections);
1755
+ }
1756
+ catch (err) {
1757
+ console.warn(`[WARNING] project-memory update failed for ${found.id}: ${err.message}`);
1758
+ }
1759
+ }
1760
+ if (!isCompactTicketOutput(opts) && !opts.quietAutoArchive) {
1761
+ console.log(`ticket archive: ${moveToArchiveStorage ? "moved ticket to" : "marked ticket archived in place"} ${toWsRelativePath(opts.cwd, targetAbsPath)}`);
1762
+ }
1763
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1764
+ if (entryIdx >= 0) {
1765
+ indexJson.entries[entryIdx].fileName = fileName;
1766
+ indexJson.entries[entryIdx].status = "archived";
1767
+ if (archiveDestination)
1768
+ indexJson.entries[entryIdx].archiveYearMonth = archiveDestination.archiveYearMonth;
1769
+ else
1770
+ delete indexJson.entries[entryIdx].archiveYearMonth;
1771
+ delete indexJson.entries[entryIdx].archiveDay;
1772
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1773
+ }
1774
+ const archivedRelativePath = toRepoRelativePath(cwd, targetAbsPath);
1775
+ if (!isCompactTicketOutput(opts) && !opts.quietAutoArchive) {
1776
+ console.log("ticket archive: final ticket path " + archivedRelativePath);
1777
+ }
1778
+ return { id: found.id, path: archivedRelativePath };
1779
+ }
1780
+ function autoArchiveDoneTickets(cwd, indexJson, opts = {}) {
1781
+ const ticketDir = detectConsumerTicketDir(cwd);
1782
+ if (!ticketDir)
1783
+ return [];
1784
+ const candidates = (indexJson.entries || [])
1785
+ .filter(isAutoArchivableDoneEntry)
1786
+ .sort(oldestFirst);
1787
+ const archived = [];
1788
+ for (const candidate of candidates) {
1789
+ let result = null;
1790
+ try {
1791
+ result = archiveTicketEntry({
1792
+ cwd,
1793
+ ticketDir,
1794
+ indexJson,
1795
+ found: candidate,
1796
+ opts: { ...opts, skipKnowledgeDistill: true, skipWorkflowQualityGate: true, quietAutoArchive: true }
1797
+ });
1798
+ }
1799
+ catch (err) {
1800
+ if (!isCompactTicketOutput(opts) && !opts.quietAutoArchive) {
1801
+ console.warn(`[AUTO-ARCHIVE] skipped ${candidate.id}: ${err.message || err}`);
1802
+ }
1803
+ continue;
1804
+ }
1805
+ if (result?.id) {
1806
+ archived.push(result);
1807
+ }
1808
+ }
1809
+ if (archived.length > 0) {
1810
+ writeTicketIndexJson(cwd, indexJson, opts);
1811
+ }
1812
+ return archived;
1813
+ }
1814
+ function isArchiveStorageFile(cwd, absPath) {
1815
+ const relPath = toPosixPath(toRepoRelativePath(cwd, absPath));
1816
+ // #080: ticket-root-relative paths use "archive/"; the deuk-root/tickets prefix
1817
+ // only appears on legacy in-workspace paths. Posix "/" is fixed (relPath is posix).
1818
+ return relPath.startsWith("archive/") || relPath.startsWith(`${DEUK_ROOT_DIR}/${TICKET_SUBDIR}/archive/`);
1819
+ }
1820
+ function isArchiveSweepCandidate(entry, excludeTicketId) {
1821
+ // Never sweep the ticket currently being processed (excludeTicketId). This is
1822
+ // not an "active marker" — it is just the in-flight ticket of the current
1823
+ // command. Only already-archived or completed (phase >= 4) tickets are swept.
1824
+ if (!entry?.id || entry.id === excludeTicketId)
1825
+ return false;
1826
+ const status = String(entry.status || "").toLowerCase();
1827
+ const phase = Number(entry.phase || 1);
1828
+ return status === "archived" || (phase >= 4 && AUTO_ARCHIVE_DONE_STATUSES.has(status));
1829
+ }
1830
+ function runBestEffortArchiveMoveSweep(cwd, indexJson, excludeTicketId, opts = {}) {
1831
+ const ticketDir = detectConsumerTicketDir(cwd);
1832
+ if (!ticketDir)
1833
+ return { moved: [], errors: [] };
1834
+ const moved = [];
1835
+ const errors = [];
1836
+ const candidates = (indexJson.entries || [])
1837
+ .filter(entry => isArchiveSweepCandidate(entry, excludeTicketId))
1838
+ .sort(oldestFirst);
1839
+ for (const candidate of candidates) {
1840
+ try {
1841
+ const candidateAbsPath = resolveTicketEntryOrComputedPath(cwd, candidate);
1842
+ if (!existsSync(candidateAbsPath) || isArchiveStorageFile(cwd, candidateAbsPath))
1843
+ continue;
1844
+ const result = archiveTicketEntry({
1845
+ cwd,
1846
+ ticketDir,
1847
+ indexJson,
1848
+ found: candidate,
1849
+ opts: { ...opts, moveFiles: true, compact: true, skipKnowledgeDistill: true, quietAutoArchive: true }
1850
+ });
1851
+ if (result?.id)
1852
+ moved.push(result);
1853
+ }
1854
+ catch (err) {
1855
+ errors.push({ id: candidate.id, error: err.message || String(err) });
1856
+ if (!isCompactTicketOutput(opts)) {
1857
+ console.warn(`[ARCHIVE-SWEEP] skipped ${candidate.id}: ${err.message || err}`);
1858
+ }
1859
+ }
1860
+ }
1861
+ if (moved.length > 0) {
1862
+ writeTicketIndexJson(cwd, indexJson, opts);
1863
+ }
1864
+ return { moved, errors };
1865
+ }
1866
+ function canAutoArchiveOpenLimit(indexJson) {
1867
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
1868
+ if (openRows.length <= MAX_OPEN_TICKETS) {
1869
+ return { needed: 0, candidates: [], ok: true };
1870
+ }
1871
+ const needed = openRows.length - MAX_OPEN_TICKETS;
1872
+ const currentActiveId = indexJson.activeTicketId;
1873
+ const candidates = openRows
1874
+ .filter(e => e.id !== currentActiveId)
1875
+ .sort(oldestFirst);
1876
+ return {
1877
+ needed,
1878
+ candidates,
1879
+ ok: candidates.length >= needed
1880
+ };
1881
+ }
1882
+ function autoArchiveOpenLimitTickets(cwd, indexJson, opts = {}) {
1883
+ const ticketDir = detectConsumerTicketDir(cwd);
1884
+ if (!ticketDir)
1885
+ return [];
1886
+ const { needed, candidates, ok } = canAutoArchiveOpenLimit(indexJson);
1887
+ if (needed <= 0 || !ok)
1888
+ return [];
1889
+ const archived = [];
1890
+ // #694: CLI 자체 메시지는 에이전트 노드 신호를 흐리는 노이즈 — compact(에이전트
1891
+ // non-interactive) 출력에선 내지 않는다.
1892
+ if (!isCompactTicketOutput(opts)) {
1893
+ console.warn("[AUTO-CLEANUP] Open-ticket limit reached. 자동으로 티켓 정리를 진행하겠습니다.");
1894
+ }
1895
+ for (const candidate of candidates) {
1896
+ if (archived.length >= needed)
1897
+ break;
1898
+ try {
1899
+ const result = archiveTicketEntry({
1900
+ cwd,
1901
+ ticketDir,
1902
+ indexJson,
1903
+ found: candidate,
1904
+ opts: { ...opts, skipKnowledgeDistill: true, quietAutoArchive: true }
1905
+ });
1906
+ if (!result?.id)
1907
+ continue;
1908
+ archived.push(result);
1909
+ if (!isCompactTicketOutput(opts)) {
1910
+ console.warn(`[AUTO-CLEANUP] ${candidate.id} archived to stay within the open-ticket limit.`);
1911
+ }
1912
+ }
1913
+ catch (err) {
1914
+ if (!isCompactTicketOutput(opts)) {
1915
+ console.warn(`[AUTO-CLEANUP] skipped ${candidate.id}: ${err.message || err}`);
1916
+ }
1917
+ }
1918
+ }
1919
+ if (archived.length > 0) {
1920
+ writeTicketIndexJson(cwd, indexJson, opts);
1921
+ }
1922
+ return archived;
1923
+ }
1924
+ function rollbackCreatedTicket(cwd, abs, rollbackIndexJson, opts = {}) {
1925
+ if (opts.dryRun)
1926
+ return;
1927
+ rmSync(abs, { force: true });
1928
+ writeTicketIndexJson(cwd, rollbackIndexJson, opts);
1929
+ }
1930
+ function buildCreateRollbackIndex(currentIndexJson, ticketId, previousIndexJson) {
1931
+ return {
1932
+ ...currentIndexJson,
1933
+ activeTicketId: previousIndexJson.activeTicketId || "",
1934
+ entries: (currentIndexJson.entries || []).filter(entry => entry.id !== ticketId)
1935
+ };
1936
+ }
1937
+ export async function runTicketCreate(opts) {
1938
+ opts = hydrateCreateTextInputs(opts);
1939
+ applyTicketWorkspacePromptDispatch(opts, [opts.summary, opts.content].filter(Boolean).join("\n"));
1940
+ applyTicketRootContext(opts, { createIfMissing: true });
1941
+ if (!opts.title && !opts.summary && !opts.ref) {
1942
+ console.log(buildTicketCreateGuide(opts));
1943
+ return;
1944
+ }
1945
+ const inferred = opts.ref ? inferRefTitleAndTopic(opts) : null;
1946
+ const title = opts.title || inferred?.title || opts.summary || "ticket";
1947
+ const titleSlug = requireNonEmptySlug(opts.title || inferred?.slug || title, "ticket title");
1948
+ const group = toSlug(opts.group || "main");
1949
+ await ensurePhase0Validation(opts);
1950
+ let path, source;
1951
+ if (opts.ref) {
1952
+ path = resolveReferencedTicketPath(opts);
1953
+ source = "ticket-reference";
1954
+ }
1955
+ else {
1956
+ // Create in the resolved ticket workspace.
1957
+ const ticketDir = detectConsumerTicketDir(opts.cwd, { createIfMissing: true });
1958
+ if (!ticketDir) {
1959
+ throw new Error("ticket create requires an AgentFlow-managed directory with .deuk. Run init or choose a managed sibling workspace.");
1960
+ }
1961
+ let parsedPlan = null;
1962
+ let finalTitle = title;
1963
+ let finalTitleSlug = titleSlug;
1964
+ if (typeof opts.planBody === "string" && opts.planBody.trim()) {
1965
+ parsedPlan = parsePlan("inline-plan-body.md", normalizePhase1PlanBodyHeadings(opts.planBody));
1966
+ finalTitle = opts.title || parsedPlan.title || title;
1967
+ finalTitleSlug = requireNonEmptySlug(finalTitle, "ticket title");
1968
+ }
1969
+ const indexJson = loadTicketIndex(opts);
1970
+ const reusableTicket = findReusableCompletedTicket(indexJson, finalTitleSlug, opts);
1971
+ if (reusableTicket) {
1972
+ throw new Error(buildReusableTicketCreateError(reusableTicket, finalTitleSlug));
1973
+ }
1974
+ // Smart close: check previous active ticket's completion state before deciding
1975
+ const activeId = indexJson.activeTicketId;
1976
+ if (activeId) {
1977
+ const activeEntry = indexJson.entries.find(e => e.id === activeId && (e.status === "open" || e.status === "active"));
1978
+ if (activeEntry) {
1979
+ const activeDoc = readTicketDocument(opts.cwd, activeEntry, { action: "ticket create", requireExists: false });
1980
+ const absPath = activeDoc.absPath;
1981
+ let shouldClose = false;
1982
+ let reason = "";
1983
+ if (activeDoc.exists) {
1984
+ try {
1985
+ const { meta, content } = activeDoc;
1986
+ const closeDecision = getAutoCloseDecision(meta, content);
1987
+ shouldClose = closeDecision.shouldClose;
1988
+ reason = closeDecision.reason;
1989
+ }
1990
+ catch (err) {
1991
+ reason = "could not read ticket file";
1992
+ }
1993
+ }
1994
+ if (shouldClose) {
1995
+ if (opts.dryRun) {
1996
+ if (!isCompactTicketOutput(opts)) {
1997
+ console.log(`[DRY-RUN] Would auto-close ${activeId} (${reason}).`);
1998
+ }
1999
+ }
2000
+ else {
2001
+ activeEntry.status = "closed";
2002
+ activeEntry.phase = 4;
2003
+ activeEntry.updatedAt = new Date().toISOString();
2004
+ // Sync to frontmatter
2005
+ if (existsSync(absPath)) {
2006
+ try {
2007
+ const body = readFileSync(absPath, "utf8");
2008
+ const parsed = parseFrontMatter(body);
2009
+ if (parsed.parseError)
2010
+ throw new Error(parsed.parseError);
2011
+ const ticketDocMeta = splitTicketDocMetaSection(parsed.content || "");
2012
+ parsed.meta.status = "closed";
2013
+ parsed.meta.phase = 4;
2014
+ writeTicketDocument(absPath, { meta: parsed.meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
2015
+ }
2016
+ catch (err) { /* skip */ }
2017
+ }
2018
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
2019
+ try {
2020
+ archiveTicketEntry({
2021
+ cwd: opts.cwd,
2022
+ ticketDir,
2023
+ indexJson,
2024
+ found: activeEntry,
2025
+ opts: { ...opts, skipWorkflowQualityGate: true, quietAutoArchive: true }
2026
+ });
2027
+ }
2028
+ catch (err) {
2029
+ if (!isCompactTicketOutput(opts)) {
2030
+ console.warn(`[AUTO-CLOSE] archive skipped for ${activeId}: ${err.message || err}`);
2031
+ }
2032
+ }
2033
+ writeTicketIndexJson(opts.cwd, indexJson, opts);
2034
+ if (!isCompactTicketOutput(opts)) {
2035
+ console.log(`[AUTO-CLOSE] ${activeId} completed (${reason}).`);
2036
+ }
2037
+ }
2038
+ }
2039
+ else {
2040
+ if (!isCompactTicketOutput(opts)) {
2041
+ console.warn(`[NOTICE] Switching from ${activeId} (${reason}). Ticket stays open.`);
2042
+ }
2043
+ }
2044
+ }
2045
+ }
2046
+ const ticketId = generateTicketId(finalTitleSlug, indexJson);
2047
+ const finalFileName = `${ticketId}.md`;
2048
+ const abs = makePath(ticketDir, group, finalFileName);
2049
+ if (!opts.dryRun)
2050
+ mkdirSync(makePath(ticketDir, group), { recursive: true });
2051
+ path = toWsRelativePath(opts.cwd, abs);
2052
+ let prevTicketEntry = null;
2053
+ if (opts.chain) {
2054
+ prevTicketEntry = pickTicketEntry({ latest: true }, indexJson);
2055
+ }
2056
+ const mainTicket = group === "sub"
2057
+ ? resolveMainTicketForSubTicket(indexJson, opts, prevTicketEntry)
2058
+ : undefined;
2059
+ if (group === "sub" && !mainTicket) {
2060
+ throw new Error("ticket create: sub tickets require --parent/--main-ticket or an existing main ticket to track.");
2061
+ }
2062
+ const summary = (opts.summary || parsedPlan?.summary || finalTitle || "ticket").trim();
2063
+ const promptText = [summary, finalTitle, parsedPlan?.body].filter(Boolean).join("\n");
2064
+ const docsLanguage = resolveTicketDocsLanguage(opts.cwd, opts.docsLanguage, promptText);
2065
+ const rawMeta = {
2066
+ id: ticketId,
2067
+ title: finalTitle,
2068
+ breadcrumb: opts.workspaceContext?.breadcrumb || resolveWorkspaceContext(opts.cwd).breadcrumb,
2069
+ phase: 1,
2070
+ status: "open",
2071
+ workflowSource: "ticket-create",
2072
+ submodule: opts.submodule,
2073
+ project: opts.project === "global" ? undefined : opts.project,
2074
+ docsLanguage,
2075
+ evidence: opts.evidence,
2076
+ mainTicket,
2077
+ summary,
2078
+ priority: opts.priority || "P2",
2079
+ tags: opts.tags
2080
+ ? opts.tags.split(',').map(t => t.trim().replace(/^#/, '')).filter(Boolean)
2081
+ : [],
2082
+ createdAt: new Date().toISOString().replace('T', ' ').split('.')[0],
2083
+ prevTicket: prevTicketEntry ? prevTicketEntry.id : undefined,
2084
+ };
2085
+ const meta = Object.fromEntries(Object.entries(rawMeta).filter(([k, v]) => {
2086
+ if (k === 'summary')
2087
+ return v !== undefined; // summary는 필수이므로 undefined만 아니면 유지
2088
+ return v !== undefined && v !== "";
2089
+ }));
2090
+ let finalDocument = null;
2091
+ if (parsedPlan) {
2092
+ const planReasons = getPhase1PlanBodyReasons(parsedPlan.body, { title: finalTitle });
2093
+ if (planReasons.length > 0) {
2094
+ throw new Error(buildPlanBodyRequiredError(planReasons, opts));
2095
+ }
2096
+ const ticketBody = injectTicketContent(parsedPlan.body, opts.content, docsLanguage);
2097
+ const docmeta = collectTicketTransitionValidationDocMeta(opts.cwd, abs, meta, ticketBody, {}, "phase2");
2098
+ const frontmatterMeta = sanitizeFrontMatterMeta({
2099
+ ...meta,
2100
+ ...buildDocMetaFrontmatterSummary(docmeta)
2101
+ });
2102
+ finalDocument = {
2103
+ meta: frontmatterMeta,
2104
+ content: ticketBody,
2105
+ docmeta
2106
+ };
2107
+ }
2108
+ else {
2109
+ throw new Error(buildPlanBodyRequiredError(["plan_body_file_required"], opts));
2110
+ }
2111
+ let rollbackIndexJson = indexJson;
2112
+ if (!opts.dryRun)
2113
+ writeTicketDocument(abs, finalDocument);
2114
+ source = "ticket-create";
2115
+ try {
2116
+ if (!opts.dryRun) {
2117
+ runTicketWorkflowQualityGate(opts.cwd, {
2118
+ ticketAbsPath: abs,
2119
+ context: `ticket create ${ticketId}`
2120
+ });
2121
+ }
2122
+ if (opts.dryRun) {
2123
+ const simulatedIndexJson = {
2124
+ ...indexJson,
2125
+ entries: [
2126
+ ...(indexJson.entries || []),
2127
+ {
2128
+ id: ticketId,
2129
+ title: finalTitle,
2130
+ group,
2131
+ project: opts.project || "global",
2132
+ createdAt: new Date().toISOString(),
2133
+ path,
2134
+ source,
2135
+ status: "open"
2136
+ }
2137
+ ]
2138
+ };
2139
+ const autoArchiveCheck = canAutoArchiveOpenLimit(simulatedIndexJson);
2140
+ const limitError = autoArchiveCheck.ok ? null : buildOpenTicketLimitError(simulatedIndexJson);
2141
+ if (limitError) {
2142
+ throw new Error(limitError);
2143
+ }
2144
+ }
2145
+ appendTicketEntry(opts.cwd, {
2146
+ id: ticketId,
2147
+ title: finalTitle, group, project: opts.project || "global",
2148
+ mainTicket,
2149
+ createdAt: new Date().toISOString(), path, source
2150
+ }, opts);
2151
+ let autoCleanupFailed = null;
2152
+ const limitIndexJson = loadTicketIndex(opts);
2153
+ try {
2154
+ autoArchiveOpenLimitTickets(opts.cwd, limitIndexJson, opts);
2155
+ }
2156
+ catch (err) {
2157
+ autoCleanupFailed = err;
2158
+ if (!isCompactTicketOutput(opts)) {
2159
+ console.warn(`[AUTO-CLEANUP] Failed; ticket creation will continue. ${err.message || err}`);
2160
+ }
2161
+ }
2162
+ const limitError = buildOpenTicketLimitError(loadTicketIndex(opts));
2163
+ if (limitError) {
2164
+ rollbackIndexJson = buildCreateRollbackIndex(loadTicketIndex(opts), ticketId, indexJson);
2165
+ throw new Error(limitError);
2166
+ }
2167
+ }
2168
+ catch (err) {
2169
+ if (!opts.dryRun) {
2170
+ rollbackCreatedTicket(opts.cwd, abs, rollbackIndexJson, opts);
2171
+ }
2172
+ throw err;
2173
+ }
2174
+ if (!opts.dryRun) {
2175
+ const linkedPrev = updatePreviousTicketRef(opts.cwd, prevTicketEntry, ticketId);
2176
+ if (linkedPrev && !isCompactTicketOutput(opts)) {
2177
+ console.log(`Linked to previous ticket: ${linkedPrev}`);
2178
+ }
2179
+ }
2180
+ if (!isCompactTicketOutput(opts)) {
2181
+ console.log(`${opts.dryRun ? "Ticket would be created" : "Ticket created"}: ${toWsRelativePath(opts.cwd, abs)}`);
2182
+ }
2183
+ const approvalSurfaceOpts = {
2184
+ ...opts,
2185
+ docsLanguage,
2186
+ runtimeContext: buildTicketRuntimeContext(ticketId, abs, {
2187
+ phase: 1,
2188
+ status: "open",
2189
+ summary
2190
+ }, {
2191
+ cwd: opts.cwd,
2192
+ summary,
2193
+ nextAction: "user-review"
2194
+ })
2195
+ };
2196
+ printPendingApprovalSurface(ticketId, abs, approvalSurfaceOpts);
2197
+ if (!opts.dryRun) {
2198
+ printCreateApprovalGate(ticketId, opts, summary);
2199
+ }
2200
+ printUsageReminder(opts.cwd, opts);
2201
+ if (!opts.dryRun) {
2202
+ appendTelemetryEvent(opts.cwd, {
2203
+ event: "ticket_created",
2204
+ action: "ticket-create",
2205
+ ticket: ticketId,
2206
+ file: path,
2207
+ phase: 1,
2208
+ status: "open"
2209
+ });
2210
+ }
2211
+ // Remote Sync Hook
2212
+ const configSync = loadInitConfig(opts.cwd);
2213
+ if (!opts.dryRun && configSync && configSync.remoteSync && configSync.pipelineUrl) {
2214
+ syncToPipeline(configSync.pipelineUrl, { action: "create", ticket: meta });
2215
+ }
2216
+ }
2217
+ syncActiveTicketId(opts.cwd, opts);
2218
+ }
2219
+ export async function runTicketList(opts) {
2220
+ applyTicketRootContext(opts);
2221
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
2222
+ if (!ticketDir) {
2223
+ throw new Error("No ticket system found. Please run 'npx deuk-agent-flow init' first.");
2224
+ }
2225
+ const index = readTicketIndexJson(opts.cwd);
2226
+ syncActiveTicketId(opts.cwd);
2227
+ let rows = index.entries;
2228
+ if (opts.active) {
2229
+ // "active" means every live ticket (open, pre-completion), not a single
2230
+ // focus marker. There can be zero, one, or many.
2231
+ rows = rows.filter(e => isLiveTicketEntry(e));
2232
+ }
2233
+ else if (opts.archived) {
2234
+ rows = rows.filter(e => e.status === "archived");
2235
+ }
2236
+ else if (!opts.all) {
2237
+ // Default: live tickets (open; "active" kept for legacy on-disk files)
2238
+ rows = rows.filter(e => e.status === "open" || e.status === "active");
2239
+ }
2240
+ if (opts.group)
2241
+ rows = rows.filter(e => e.group === opts.group);
2242
+ if (opts.project)
2243
+ rows = rows.filter(e => e.project === opts.project);
2244
+ if (opts.submodule)
2245
+ rows = rows.filter(e => e.submodule === opts.submodule);
2246
+ if (opts.json) {
2247
+ // With --print-content, attach each ticket's body so consumers (e.g. the
2248
+ // VSCode extension) get the content straight from the CLI and never resolve
2249
+ // ticket-home / read files themselves. The CLI ran in its own environment
2250
+ // (Windows/Linux) so it reads from the correct home regardless of caller.
2251
+ const payloadRows = opts.printContent
2252
+ ? rows.map(e => {
2253
+ let content = "";
2254
+ try {
2255
+ const doc = readTicketDocument(opts.cwd, e, { action: "ticket list", requireExists: false });
2256
+ content = doc.exists ? doc.content : "";
2257
+ }
2258
+ catch { /* missing/corrupt ticket → empty body */ }
2259
+ return { ...e, content };
2260
+ })
2261
+ : rows;
2262
+ // #756: --with-total wraps the (filtered) rows with the workspace-wide total
2263
+ // ticket count (all statuses incl. archived) so the VS Code panel can show
2264
+ // "open / total" without a second, heavy archive-loading call. INDEX.json
2265
+ // already holds every entry, so this is just index.entries.length — no readdir.
2266
+ if (opts.withTotal) {
2267
+ console.log(JSON.stringify({ tickets: payloadRows, total: index.entries.length }, null, 2));
2268
+ return;
2269
+ }
2270
+ console.log(JSON.stringify(payloadRows, null, 2));
2271
+ return;
2272
+ }
2273
+ console.log("# ID | STATUS | PHASE | PREVIEW");
2274
+ rows.slice(0, opts.limit).forEach((e, idx) => {
2275
+ console.log(`${String(idx + 1).padEnd(2)} ${formatTicketChoice(e, index.activeTicketId)}`);
2276
+ });
2277
+ if (opts.render) {
2278
+ console.log("ticket list --render is deprecated; TICKET_LIST.md is no longer generated.");
2279
+ }
2280
+ }
2281
+ export async function runTicketStatus(opts) {
2282
+ applyTicketRootContext(opts);
2283
+ const index = loadTicketIndex(opts);
2284
+ const found = pickTicketEntry(opts, index);
2285
+ if (!found) {
2286
+ // No live (open, phase < 4) ticket is in focus. Do NOT surface an archived or
2287
+ // completed ticket as if it were active. When the user named a specific ticket
2288
+ // that wasn't found, that's an error; otherwise there simply is no active
2289
+ // ticket — list the live tickets (zero or more) so the surface is honest.
2290
+ const ticketSelector = opts.ticketId || (!opts.summary && !opts.planBody && !opts.planBodyFile ? opts.title : "");
2291
+ if (ticketSelector) {
2292
+ throw new Error(`ticket status: no matching ticket found for '${ticketSelector}'`);
2293
+ }
2294
+ console.log("No active ticket (no open ticket before phase 4). Live tickets:");
2295
+ await runTicketList({ ...opts, active: false, archived: false, all: false });
2296
+ return;
2297
+ }
2298
+ const ticketDoc = readTicketDocument(opts.cwd, found, { action: "ticket status", requireExists: false });
2299
+ const absPath = ticketDoc.absPath;
2300
+ const parsed = { meta: ticketDoc.meta, content: ticketDoc.content };
2301
+ if (ticketDoc.exists)
2302
+ assertTicketWorkflowProvenance(found, parsed.meta);
2303
+ assertTicketWorkflowAction(parsed.meta, {
2304
+ fallbackStatus: found.status || "open",
2305
+ action: "status"
2306
+ });
2307
+ const phase = Number(parsed.meta.phase || 1);
2308
+ const workflowStatus = deriveTicketWorkflowStatus(parsed.meta, found.status || "open");
2309
+ const requiresExecutionPlan = isExecutionTicketStatus(workflowStatus);
2310
+ const incompleteReasons = requiresExecutionPlan ? getPhase1IncompleteReasons(opts.cwd, absPath) : [];
2311
+ const derivedStatus = incompleteReasons.length > 0 && phase === 1
2312
+ ? "phase1_incomplete"
2313
+ : workflowStatus;
2314
+ const out = {
2315
+ id: found.id,
2316
+ title: found.title,
2317
+ path: toPosixPath(resolve(ticketDoc.absPath)),
2318
+ phase,
2319
+ status: derivedStatus,
2320
+ summary: parsed.meta.summary || null,
2321
+ reasons: incompleteReasons,
2322
+ };
2323
+ if (opts.json) {
2324
+ console.log(JSON.stringify(out, null, 2));
2325
+ return;
2326
+ }
2327
+ // --status-detail is an explicit request for the full status/context block;
2328
+ // honor it even under --non-interactive (which otherwise implies compact).
2329
+ if (isCompactTicketOutput(opts) && !opts.statusDetail) {
2330
+ const reasonText = out.reasons.length === 0 ? "ok" : out.reasons.join(", ");
2331
+ printTicketStatusFlowLine(out.id, absPath, out.phase, { ...opts, docsLanguage: parsed.meta.docsLanguage });
2332
+ console.log(`${out.id} | phase=${out.phase} | status=${out.status} | ${reasonText}`);
2333
+ printUsageReminder(opts.cwd, opts);
2334
+ return;
2335
+ }
2336
+ console.log(`Ticket: ${out.id}`);
2337
+ console.log(`Status: ${out.status}`);
2338
+ console.log(`Phase: ${out.phase}`);
2339
+ console.log(`Path: ${out.path}`);
2340
+ const runtimeContext = buildTicketRuntimeContext(out.id, absPath, parsed.meta, {
2341
+ cwd: opts.cwd,
2342
+ hostWorkspaceLabel: opts.hostWorkspaceContext?.breadcrumb,
2343
+ status: out.status,
2344
+ phase: out.phase,
2345
+ summary: out.summary,
2346
+ reasons: out.reasons,
2347
+ nextAction: out.phase >= 4 || out.status === "closed" || out.status === "archived" ? "terminal-review-or-archive" : "follow-state-prompt"
2348
+ });
2349
+ printTicketStatusFlowLine(out.id, absPath, out.phase, { ...opts, docsLanguage: parsed.meta.docsLanguage, runtimeContext });
2350
+ if (opts.statusDetail || out.reasons.length > 0) {
2351
+ if (out.reasons.length === 0)
2352
+ console.log("Reasons: none");
2353
+ else
2354
+ console.log(`Reasons: ${out.reasons.join(", ")}`);
2355
+ }
2356
+ printUsageReminder(opts.cwd, opts);
2357
+ }
2358
+ export async function runTicketGuard(opts) {
2359
+ applyTicketRootContext(opts);
2360
+ applyTicketContext(opts);
2361
+ if (hasExplicitExecutionApproval(opts)) {
2362
+ opts.quietAutoArchive = true;
2363
+ }
2364
+ if (!opts.ticketId && !opts.latest) {
2365
+ throw new Error("ticket guard: --id or --latest is required before set_workflow_context.");
2366
+ }
2367
+ const index = loadTicketIndex(opts);
2368
+ const found = pickTicketEntry(opts, index);
2369
+ if (!found) {
2370
+ throw new Error("ticket guard: no matching durable ticket found; do not call set_workflow_context.");
2371
+ }
2372
+ const ticketDoc = readTicketDocument(opts.cwd, found, { action: "ticket guard", requireExists: false });
2373
+ if (!ticketDoc.exists) {
2374
+ throw new Error(`ticket guard: durable ticket file missing for ${found.id}; do not call set_workflow_context.`);
2375
+ }
2376
+ const { meta: parsedMeta, content } = ticketDoc;
2377
+ const absPath = ticketDoc.absPath;
2378
+ assertTicketWorkflowProvenance(found, parsedMeta);
2379
+ const workflowStatus = deriveTicketWorkflowStatus(parsedMeta, found.status || "open");
2380
+ const phase = Number(parsedMeta.phase || 1);
2381
+ if (phase >= 4 || isTerminalTicketStatus(workflowStatus)) {
2382
+ throw new Error(`[TICKET WORKFLOW BLOCKED] ticket guard blocked ${found.id}: terminal ticket cannot open execution context. status=${workflowStatus}, phase=${phase}. Reopen with ticket move only after an explicit reopen decision.`);
2383
+ }
2384
+ assertTicketWorkflowAction(parsedMeta, {
2385
+ fallbackStatus: found.status || "open",
2386
+ action: "guard"
2387
+ });
2388
+ if (phase === 1) {
2389
+ try {
2390
+ resolveTicketWorkflowCommandTransition(opts.cwd, found, absPath, parsedMeta, content, opts, {
2391
+ action: "guard",
2392
+ toState: "phase2"
2393
+ });
2394
+ }
2395
+ catch (err) {
2396
+ const reasonText = String(err.message || "");
2397
+ if (reasonText.includes("user_approval_missing")
2398
+ || reasonText.includes("phase1_plan_incomplete")) {
2399
+ const reasons = reasonText.split(":").pop().split(",").map(value => value.trim()).filter(Boolean);
2400
+ throw new Error(formatTicketWorkflowGateFailure(opts.cwd, absPath, found, parsedMeta, content, opts, "phase2", reasons));
2401
+ }
2402
+ throw err;
2403
+ }
2404
+ }
2405
+ runBestEffortArchiveMoveSweep(opts.cwd, index, found.id, opts);
2406
+ let finalPhase = phase;
2407
+ let finalStatus = deriveTicketWorkflowStatus(parsedMeta, found.status || "open");
2408
+ const out = {
2409
+ id: found.id,
2410
+ ticketId: found.id,
2411
+ phase: finalPhase,
2412
+ status: finalStatus,
2413
+ path: toWsRelativePath(opts.cwd, absPath)
2414
+ };
2415
+ if (opts.json) {
2416
+ console.log(JSON.stringify(out, null, 2));
2417
+ }
2418
+ else {
2419
+ console.log(`ticket-guard-ok ${out.id} | phase=${out.phase} | status=${out.status} | ${out.path}`);
2420
+ }
2421
+ return out;
2422
+ }
2423
+ export async function runTicketHandoff(opts) {
2424
+ applyTicketRootContext(opts);
2425
+ if (!opts.ticketId && !opts.latest)
2426
+ opts.latest = true;
2427
+ const index = loadTicketIndex(opts);
2428
+ const current = pickTicketEntry(opts, index);
2429
+ if (!current)
2430
+ throw new Error("ticket handoff: no matching ticket found");
2431
+ const currentDoc = readTicketDocument(opts.cwd, current, { action: "ticket handoff", requireExists: false });
2432
+ const currentAbs = currentDoc.absPath;
2433
+ const currentMissing = !currentDoc.exists;
2434
+ const currentParsed = currentMissing ? { meta: {}, content: "" } : { meta: currentDoc.meta, content: currentDoc.content };
2435
+ const currentPhase = Number(currentParsed.meta.phase || 1);
2436
+ const currentReasons = currentMissing ? ["ticket_file_missing"] : getPhase1IncompleteReasons(opts.cwd, currentAbs);
2437
+ const currentStatus = currentReasons.length > 0 && currentPhase === 1
2438
+ ? "phase1_incomplete"
2439
+ : (currentParsed.meta.status || current.status || "open");
2440
+ const rows = filterTicketEntries(index.entries, opts)
2441
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
2442
+ // Prefer the focus pointer (activeTicketId) if it isn't the current ticket,
2443
+ // then any other live ticket. "active" status is legacy-only.
2444
+ let nextTicket = rows.find(e => e.id === index.activeTicketId && e.id !== current.id);
2445
+ if (!nextTicket)
2446
+ nextTicket = rows.find(e => (e.status === "active" || e.status === "open") && e.id !== current.id);
2447
+ const out = {
2448
+ current: {
2449
+ id: current.id,
2450
+ phase: currentPhase,
2451
+ status: currentStatus,
2452
+ path: current.path,
2453
+ reasons: currentReasons
2454
+ },
2455
+ nextTicket: nextTicket ? {
2456
+ id: nextTicket.id,
2457
+ status: nextTicket.status,
2458
+ path: nextTicket.path
2459
+ } : null,
2460
+ nextAction: nextTicket ? "continue-ticket" : "inspect-git-history",
2461
+ telemetry: (() => {
2462
+ const summary = buildTelemetrySummary(opts.cwd);
2463
+ if (!summary)
2464
+ return null;
2465
+ return {
2466
+ logEntries: summary.logEntries,
2467
+ coverageRate: summary.eventCoverageRate,
2468
+ tdwCoverageRate: summary.tdwCoverageRate,
2469
+ totalTokens: summary.totalTokens
2470
+ };
2471
+ })(),
2472
+ telemetrySummary: getTelemetryCompactSummary(opts.cwd)
2473
+ };
2474
+ if (opts.json) {
2475
+ console.log(JSON.stringify(out, null, 2));
2476
+ return out;
2477
+ }
2478
+ if (isCompactTicketOutput(opts)) {
2479
+ console.log(getHandoffSummary(out));
2480
+ return out;
2481
+ }
2482
+ console.log(`Current: ${out.current.id} | phase=${out.current.phase} | status=${out.current.status}`);
2483
+ console.log(`Next: ${out.nextTicket ? `${out.nextTicket.id} (${out.nextTicket.status})` : "none"}`);
2484
+ console.log(`Action: ${out.nextAction}`);
2485
+ return out;
2486
+ }
2487
+ export async function runTicketMeta(opts) {
2488
+ applyTicketRootContext(opts);
2489
+ applyTicketContext(opts);
2490
+ const index = loadTicketIndex(opts);
2491
+ const found = pickTicketEntry(opts, index);
2492
+ if (!found)
2493
+ throw new Error("ticket meta: no matching ticket found");
2494
+ if (opts.json) {
2495
+ console.log(JSON.stringify(found, null, 2));
2496
+ }
2497
+ else {
2498
+ console.log(`Ticket Meta [${found.id}]`);
2499
+ Object.entries(found).forEach(([k, v]) => console.log(` ${k}: ${v}`));
2500
+ }
2501
+ }
2502
+ export async function runTicketConnect(opts) {
2503
+ applyTicketRootContext(opts);
2504
+ const config = loadInitConfig(opts.cwd);
2505
+ const url = opts.remote || config?.pipelineUrl;
2506
+ if (!url)
2507
+ throw new Error("ticket connect: no pipeline URL configured or provided via --remote");
2508
+ console.log(`Connecting to AI Pipeline at ${url} ...`);
2509
+ const success = await syncToPipeline(url, { action: "ping", timestamp: new Date().toISOString() });
2510
+ if (success) {
2511
+ console.log("SUCCESS: Pipeline is reachable.");
2512
+ }
2513
+ else {
2514
+ console.error("FAILED: Could not connect to pipeline or returned non-OK status.");
2515
+ }
2516
+ }
2517
+ export async function runTicketEvidenceCheck(opts) {
2518
+ applyTicketRootContext(opts);
2519
+ applyTicketContext(opts);
2520
+ if (!opts.claim || !String(opts.claim).trim()) {
2521
+ throw new Error("ticket evidence requires --claim <text> to compare with ticket content.");
2522
+ }
2523
+ const index = loadTicketIndex(opts);
2524
+ const target = pickTicketEntry(opts, index);
2525
+ if (!target) {
2526
+ throw new Error("ticket evidence: no matching ticket found.");
2527
+ }
2528
+ const { absPath, meta, content } = readTicketDocument(opts.cwd, target, { action: "ticket evidence", requireExists: true });
2529
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
2530
+ const implementationGuard = Array.isArray(opts.changedFiles)
2531
+ ? getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles })
2532
+ : { ok: true, reasons: [] };
2533
+ if (!result.ok || !implementationGuard.ok) {
2534
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
2535
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.id} has insufficient evidence coverage for claim "${opts.claim}": ${reasons.join(", ")}.`);
2536
+ }
2537
+ if (opts.json) {
2538
+ console.log(JSON.stringify(result.docmeta, null, 2));
2539
+ }
2540
+ else {
2541
+ console.log(`[evidence-ok] ${target.id} ${result.docmeta.validation.status} claim coverage ${result.coveredTerms}/${result.claimTerms}`);
2542
+ }
2543
+ }
2544
+ export async function runTicketEvidenceReport(opts) {
2545
+ applyTicketRootContext(opts);
2546
+ applyTicketContext(opts);
2547
+ if (!opts.claim || !String(opts.claim).trim()) {
2548
+ throw new Error("ticket report requires --claim <text> when generating a claim-bound report.");
2549
+ }
2550
+ const index = loadTicketIndex(opts);
2551
+ const target = pickTicketEntry(opts, index);
2552
+ if (!target) {
2553
+ throw new Error("ticket report: no matching ticket found.");
2554
+ }
2555
+ const { absPath, meta, content } = readTicketDocument(opts.cwd, target, { action: "ticket report", requireExists: true });
2556
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
2557
+ const implementationGuard = Array.isArray(opts.changedFiles)
2558
+ ? getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles })
2559
+ : { ok: true, reasons: [] };
2560
+ if (!result.ok || !implementationGuard.ok) {
2561
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
2562
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.id} cannot produce claim-bound report for "${opts.claim}": ${reasons.join(", ")}.`);
2563
+ }
2564
+ if (opts.json) {
2565
+ console.log(JSON.stringify(result, null, 2));
2566
+ return;
2567
+ }
2568
+ console.log(`Claim-bound ticket report: ${target.id}`);
2569
+ console.log(`Claim: ${opts.claim}`);
2570
+ console.log(`Coverage: ${result.coveredTerms}/${result.claimTerms}`);
2571
+ for (const [label, value] of Object.entries(result.sections)) {
2572
+ if (!value)
2573
+ continue;
2574
+ console.log(`\n## ${label}`);
2575
+ console.log(value);
2576
+ }
2577
+ }
2578
+ export async function runTicketClose(opts) {
2579
+ applyTicketRootContext(opts);
2580
+ applyTicketContext(opts);
2581
+ if (!opts.ticketId && !opts.latest) {
2582
+ if (opts.nonInteractive || !process.stdout.isTTY) {
2583
+ opts.latest = true;
2584
+ }
2585
+ else {
2586
+ await withReadline(async (rl) => {
2587
+ const index = loadTicketIndex(opts);
2588
+ const choices = index.entries
2589
+ .filter(e => e.status !== "closed" && e.status !== "cancelled")
2590
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.id }));
2591
+ if (choices.length > 0) {
2592
+ opts.ticketId = await selectOne(rl, "Choose a ticket to close:", choices);
2593
+ }
2594
+ else {
2595
+ throw new Error("No open tickets found to close.");
2596
+ }
2597
+ });
2598
+ }
2599
+ }
2600
+ // Respect --status flag (e.g. 'cancelled', 'wontfix'); default to 'closed'
2601
+ if (!opts.status)
2602
+ opts.status = "closed";
2603
+ const previousIndex = readTicketIndexJson(opts.cwd);
2604
+ const targetEntry = pickTicketEntry(opts, previousIndex);
2605
+ if (!targetEntry) {
2606
+ throw new Error("No matching ticket found to update status");
2607
+ }
2608
+ const { absPath: abs, meta: closeMeta, body: previousBody, content: closeContent } = readTicketDocument(opts.cwd, targetEntry, { action: "ticket close", requireExists: true });
2609
+ const parsedForClose = { meta: closeMeta, content: closeContent };
2610
+ try {
2611
+ resolveTicketWorkflowCommandTransition(opts.cwd, targetEntry, abs, closeMeta, closeContent, opts, {
2612
+ action: "close",
2613
+ toState: "end"
2614
+ });
2615
+ }
2616
+ catch (err) {
2617
+ if (String(err.message).includes("TICKET WORKFLOW BLOCKED")) {
2618
+ throw new Error(`${err.message}\n` +
2619
+ `Hint: "close" is not allowed in the current state. ` +
2620
+ `Use "ticket move --to end" to transition, or "ticket discard --id ${targetEntry.id} --workspace ${opts.workspace || ""} --non-interactive" to abandon.`);
2621
+ }
2622
+ throw err;
2623
+ }
2624
+ const closePlanningReasons = getCloseWorkflowReasons(parsedForClose.meta, parsedForClose.content);
2625
+ if (closePlanningReasons.length) {
2626
+ throw new Error(`[VALIDATION FAILED] Ticket ${targetEntry.id} cannot close without complete main-ticket analysis/design evidence: ${[...new Set(closePlanningReasons)].join(", ")}.`);
2627
+ }
2628
+ try {
2629
+ const entry = updateTicketEntryStatus(opts.cwd, opts);
2630
+ runTicketWorkflowQualityGate(opts.cwd, {
2631
+ ticketAbsPath: abs,
2632
+ context: `ticket close ${entry.id}`
2633
+ });
2634
+ if (String(opts.status || "").toLowerCase() === "closed") {
2635
+ syncActiveTicketId(opts.cwd);
2636
+ }
2637
+ const finalPath = computeTicketPath(entry);
2638
+ const finalAbsPath = makePath(opts.cwd, finalPath);
2639
+ appendTelemetryEvent(opts.cwd, {
2640
+ event: "ticket_closed",
2641
+ action: "ticket-close",
2642
+ ticket: entry.id,
2643
+ file: finalPath,
2644
+ phase: 4,
2645
+ status: opts.status
2646
+ });
2647
+ const normalizedStatus = String(opts.status || "closed").toLowerCase();
2648
+ if (normalizedStatus === "closed") {
2649
+ printTicketEndLine(entry.id, finalAbsPath, {
2650
+ ...opts,
2651
+ docsLanguage: closeMeta.docsLanguage,
2652
+ runtimeContext: buildTicketRuntimeContext(entry.id, finalAbsPath, { ...closeMeta, phase: 4, status: normalizedStatus }, {
2653
+ cwd: opts.cwd,
2654
+ hostWorkspaceLabel: opts.hostWorkspaceContext?.breadcrumb,
2655
+ summary: closeMeta.summary,
2656
+ nextAction: "final-response-only"
2657
+ })
2658
+ });
2659
+ }
2660
+ else {
2661
+ console.log(formatTicketStatusLine(normalizedStatus, entry.id, finalAbsPath));
2662
+ }
2663
+ return { status: opts.status, ticket: entry.id, path: finalPath };
2664
+ }
2665
+ catch (err) {
2666
+ rollbackTicketWorkflowArtifacts(opts.cwd, previousIndex, previousBody, abs, opts);
2667
+ throw err;
2668
+ }
2669
+ }
2670
+ export async function runTicketUse(opts) {
2671
+ applyTicketRootContext(opts);
2672
+ applyTicketContext(opts);
2673
+ const index = loadTicketIndex(opts);
2674
+ const scopedEntries = filterTicketEntries(index.entries, opts);
2675
+ let targetTicketId = opts.ticketId;
2676
+ if (!targetTicketId && !opts.latest) {
2677
+ if (opts.nonInteractive) {
2678
+ throw new Error("ticket use: --id or --latest is required in non-interactive mode.");
2679
+ }
2680
+ await withReadline(async (rl) => {
2681
+ const choices = scopedEntries
2682
+ .map(e => ({ label: `${e.status === 'closed' ? '✓ ' : ''}[${e.group}] ${e.title}`, value: e.id }));
2683
+ if (choices.length > 0) {
2684
+ targetTicketId = await selectOne(rl, "Choose a ticket to use:", choices);
2685
+ }
2686
+ });
2687
+ }
2688
+ const found = opts.latest ? scopedEntries[0] : scopedEntries.find(e => String(e.id || "").includes(targetTicketId));
2689
+ if (!found) {
2690
+ const candidates = buildUseFallbackCandidates(index, opts);
2691
+ if (!opts.nonInteractive && candidates.length > 0) {
2692
+ await withReadline(async (rl) => {
2693
+ targetTicketId = await selectOne(rl, `No matching ticket found for "${targetTicketId}". Choose a ticket to use:`, candidates.map(e => ({ label: formatTicketChoice(e), value: e.id })));
2694
+ });
2695
+ const selected = index.entries.find(e => e.id === targetTicketId);
2696
+ if (selected) {
2697
+ opts.ticketId = targetTicketId;
2698
+ return runTicketUse({ ...opts, latest: false });
2699
+ }
2700
+ }
2701
+ throw new Error(buildUseNoMatchError(targetTicketId, candidates));
2702
+ }
2703
+ const foundDoc = readTicketDocument(opts.cwd, found, { action: "ticket use", requireExists: true });
2704
+ assertTicketWorkflowProvenance(found, foundDoc.meta);
2705
+ assertTicketWorkflowAction(foundDoc.meta, {
2706
+ fallbackStatus: found.status || "open",
2707
+ action: "use"
2708
+ });
2709
+ // #685: LangGraph 모델 — 현재 노드는 .md phase가 SSOT다. 세션별 claim/쿠키로
2710
+ // "누가 이 티켓을 잡았나"를 추적하는 레이어는 제거됨(단일 사용자, env 미전파 환경에서
2711
+ // 폴백으로 무너지는 근원). ticket use는 노드 컨텍스트를 로드만 하며 아무 상태도 쓰지 않는다.
2712
+ const absPath = toPosixPath(foundDoc.absPath);
2713
+ if (opts.pathOnly) {
2714
+ printTicketSelectionLine(found.id, absPath, { ...opts, docsLanguage: foundDoc.meta.docsLanguage });
2715
+ return;
2716
+ }
2717
+ printTicketSelectionLine(found.id, absPath, { ...opts, docsLanguage: foundDoc.meta.docsLanguage });
2718
+ printTicketUseNextSteps(found, foundDoc, opts);
2719
+ if (opts.printContent && !isCompactTicketOutput(opts))
2720
+ console.log("\n" + readFileSync(foundDoc.absPath, "utf8"));
2721
+ }
2722
+ function extractMarkdownSections(content, sectionNames) {
2723
+ const sections = {};
2724
+ for (const name of sectionNames) {
2725
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2726
+ const regex = new RegExp(`^##\\s+${escapedName}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
2727
+ const match = content.match(regex);
2728
+ if (match) {
2729
+ const value = match[1].trim();
2730
+ if (value)
2731
+ sections[name] = value;
2732
+ }
2733
+ }
2734
+ return sections;
2735
+ }
2736
+ // #727: knowledge distill 제거 — knowledge는 cwd(.deuk/knowledge)에 쓰던 의미없는 레거시
2737
+ // 기록이다(언니 지시). 티켓 archive 시 cwd write가 발생하지 않도록 기능을 통째로 제거. no-op.
2738
+ // #685: project-memory.md 자동 갱신 제거 — project-memory는 진행 상태를 담던 무의미한
2739
+ // 레이어다(언니 지시). 티켓 close 시 결정/마일스톤을 자동으로 박지 않는다. no-op.
2740
+ function updateProjectMemoryFromTicket(cwd, ticketId, meta, ticketSections) {
2741
+ return;
2742
+ }
2743
+ function appendTelemetryEvent(cwd, entry) {
2744
+ try {
2745
+ appendInternalWorkflowEvent(cwd, {
2746
+ event: entry.event || "workflow_event",
2747
+ ticket: entry.ticket || "",
2748
+ action: entry.action || entry.event || "workflow-event",
2749
+ file: entry.file || "",
2750
+ phase: entry.phase,
2751
+ status: entry.status || "",
2752
+ ragResult: entry.ragResult || "",
2753
+ localFallback: Boolean(entry.localFallback),
2754
+ knowledgeAction: entry.knowledgeAction || "",
2755
+ knowledgeSourceKind: entry.knowledgeSourceKind || "",
2756
+ knowledgeIngestionCategory: entry.knowledgeIngestionCategory || "",
2757
+ knowledgeCorpus: entry.knowledgeCorpus || "",
2758
+ knowledgeOriginTool: entry.knowledgeOriginTool || "",
2759
+ knowledgeFreshness: entry.knowledgeFreshness || "",
2760
+ tokenQuality: entry.tokenQuality || "",
2761
+ savedTokens: Number(entry.savedTokens || 0)
2762
+ });
2763
+ }
2764
+ catch (err) {
2765
+ console.warn(`[WARNING] Telemetry append failed for ${entry.ticket || "unknown"}: ${err.message}`);
2766
+ }
2767
+ }
2768
+ export function pickTicketEntry(opts, indexJson) {
2769
+ const rows = filterTicketEntries(indexJson.entries, opts)
2770
+ .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
2771
+ const ticketSelector = opts.ticketId || (!opts.summary && !opts.planBody && !opts.planBodyFile ? opts.title : "");
2772
+ if (ticketSelector) {
2773
+ const key = String(ticketSelector).toLowerCase();
2774
+ const localMatch = selectTicketEntryBySelector(rows, key);
2775
+ if (localMatch)
2776
+ return localMatch;
2777
+ if (opts.ticketId) {
2778
+ const durableLocalMatch = findTicketEntryInWorkspaceBySelector(opts.cwd, key, opts);
2779
+ if (durableLocalMatch)
2780
+ return durableLocalMatch;
2781
+ const siblingMatch = findSiblingTicketEntryBySelector(opts, key);
2782
+ if (siblingMatch)
2783
+ return siblingMatch;
2784
+ }
2785
+ return null;
2786
+ }
2787
+ if (rows.length === 0)
2788
+ return null;
2789
+ // No explicit selector: there is no artificial "active" focus pointer anymore.
2790
+ // A ticket is "active" iff it is open and pre-completion (phase < 4). Return the
2791
+ // most recently created such live ticket; if none are live, return null instead
2792
+ // of silently surfacing an archived/completed ticket as if it were active.
2793
+ const live = rows.filter(e => isLiveTicketEntry(e));
2794
+ return live[0] || null;
2795
+ }
2796
+ // A live ticket is open (not archived/closed) and has not reached the completion
2797
+ // phase. This is the sole definition of "active" — there is no separate marker.
2798
+ function isLiveTicketEntry(entry) {
2799
+ if (!entry?.id)
2800
+ return false;
2801
+ const status = String(entry.status || "open").toLowerCase();
2802
+ if (status === "archived" || status === "closed")
2803
+ return false;
2804
+ return Number(entry.phase || 1) < 4;
2805
+ }
2806
+ function selectTicketEntryBySelector(entries, selector) {
2807
+ const key = String(selector || "").toLowerCase();
2808
+ if (!key)
2809
+ return null;
2810
+ const exactMatch = entries.find(entry => String(entry.id || "").toLowerCase() === key);
2811
+ if (exactMatch)
2812
+ return exactMatch;
2813
+ return entries.find(entry => String(entry.id || "").toLowerCase().includes(key)) || null;
2814
+ }
2815
+ function inferTicketGroupFromRelativePath(relativePath, fallback = "sub") {
2816
+ const normalized = String(relativePath || "").replace(/\\/g, "/");
2817
+ if (normalized.includes("/main/"))
2818
+ return "main";
2819
+ if (normalized.includes("/sub/"))
2820
+ return "sub";
2821
+ return fallback;
2822
+ }
2823
+ function inferArchiveYearMonthFromRelativePath(relativePath) {
2824
+ const match = String(relativePath || "").replace(/\\/g, "/").match(/\/archive\/(?:main|sub)\/(\d{4}-\d{2})(?:\/|$)/);
2825
+ return match ? match[1] : "";
2826
+ }
2827
+ function findTicketEntryInWorkspaceBySelector(cwd, selector, opts = {}) {
2828
+ const ticketDir = detectConsumerTicketDir(cwd);
2829
+ if (!ticketDir || !existsSync(ticketDir))
2830
+ return null;
2831
+ const matches = [];
2832
+ const stack = [ticketDir];
2833
+ while (stack.length > 0) {
2834
+ const dir = stack.pop();
2835
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
2836
+ const absPath = makePath(dir, item.name);
2837
+ if (item.isDirectory()) {
2838
+ stack.push(absPath);
2839
+ continue;
2840
+ }
2841
+ if (!item.isFile() || !item.name.endsWith(".md"))
2842
+ continue;
2843
+ const body = readFileSync(absPath, "utf8");
2844
+ const { meta } = parseFrontMatter(body);
2845
+ const id = String(meta.id || item.name.replace(/\.md$/i, "")).trim();
2846
+ if (!id)
2847
+ continue;
2848
+ const entry = {
2849
+ id,
2850
+ title: meta.title || id,
2851
+ group: normalizeTicketGroup(meta.group || inferTicketGroupFromRelativePath(toWsRelativePath(cwd, absPath)), "sub"),
2852
+ fileName: item.name,
2853
+ project: meta.project || "global",
2854
+ submodule: meta.submodule || "",
2855
+ createdAt: meta.createdAt || "",
2856
+ updatedAt: meta.updatedAt || "",
2857
+ status: meta.status || "open",
2858
+ phase: Number(meta.phase || 1),
2859
+ archiveYearMonth: meta.archiveYearMonth || inferArchiveYearMonthFromRelativePath(toWsRelativePath(cwd, absPath)),
2860
+ path: toWsRelativePath(cwd, absPath)
2861
+ };
2862
+ const filtered = filterTicketEntries([entry], opts);
2863
+ if (selectTicketEntryBySelector(filtered, selector)) {
2864
+ matches.push(entry);
2865
+ }
2866
+ }
2867
+ }
2868
+ if (matches.length === 0)
2869
+ return null;
2870
+ if (matches.length > 1) {
2871
+ throw new Error(`ticket guard: ticket id is ambiguous in workspace ${cwd}: ${matches.map(match => match.id).join(", ")}`);
2872
+ }
2873
+ return matches[0];
2874
+ }
2875
+ function findSiblingTicketEntryBySelector(opts, selector) {
2876
+ const candidates = loadWorkspaceCandidates(opts.invocationCwd || opts.cwd);
2877
+ if (!candidates || !Array.isArray(candidates.workspaces) || candidates.workspaces.length === 0)
2878
+ return null;
2879
+ const matches = [];
2880
+ const currentRoot = resolve(opts.cwd);
2881
+ for (const workspace of candidates.workspaces) {
2882
+ const workspaceRoot = resolve(workspace.path);
2883
+ if (workspaceRoot === currentRoot)
2884
+ continue;
2885
+ // The home ticket store is not a sibling workspace — skip it so tickets that
2886
+ // physically live under ~/.deuk-agent/tickets/<key> aren't double-counted (#623).
2887
+ if (isHomeStoreRootDir(workspaceRoot))
2888
+ continue;
2889
+ const siblingIndex = readTicketIndexJson(workspaceRoot);
2890
+ const siblingRows = filterTicketEntries(siblingIndex.entries, opts);
2891
+ const match = selectTicketEntryBySelector(siblingRows, selector)
2892
+ || findTicketEntryInWorkspaceBySelector(workspaceRoot, selector, opts);
2893
+ if (match) {
2894
+ matches.push({ workspace: workspaceRoot, id: workspace.id, entry: match });
2895
+ }
2896
+ }
2897
+ if (matches.length === 0)
2898
+ return null;
2899
+ if (matches.length > 1) {
2900
+ throw new Error(`ticket guard: ticket id is ambiguous across sibling workspaces: ${matches.map(match => `${match.entry.id} @ ${match.workspace}`).join(", ")}`);
2901
+ }
2902
+ opts.cwd = matches[0].workspace;
2903
+ opts.workspace = matches[0].id;
2904
+ return matches[0].entry;
2905
+ }
2906
+ function filterTicketEntries(entries, opts = {}) {
2907
+ return [...(entries || [])].filter(entry => {
2908
+ if (opts.project && entry.project !== opts.project)
2909
+ return false;
2910
+ if (opts.submodule && entry.submodule !== opts.submodule)
2911
+ return false;
2912
+ return true;
2913
+ });
2914
+ }
2915
+ export async function runTicketArchive(opts) {
2916
+ applyTicketRootContext(opts);
2917
+ applyTicketContext(opts);
2918
+ const indexJson = loadTicketIndex(opts);
2919
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
2920
+ if (!opts.latest && !opts.ticketId) {
2921
+ if (opts.nonInteractive) {
2922
+ throw new Error("ticket archive: --id or --latest is required in non-interactive mode.");
2923
+ }
2924
+ await withReadline(async (rl) => {
2925
+ const choices = indexJson.entries
2926
+ .filter(e => e.status !== "archived")
2927
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.id }));
2928
+ if (choices.length > 0) {
2929
+ opts.ticketId = await selectOne(rl, "Choose a ticket to archive:", choices);
2930
+ }
2931
+ else {
2932
+ throw new Error("No active tickets found to archive.");
2933
+ }
2934
+ });
2935
+ }
2936
+ const found = pickTicketEntry(opts, indexJson);
2937
+ if (!found)
2938
+ throw new Error("ticket archive: no matching entry");
2939
+ const archiveDoc = readTicketDocument(opts.cwd, found, { action: "ticket archive", requireExists: true });
2940
+ assertTicketWorkflowAction(archiveDoc.meta, {
2941
+ fallbackStatus: found.status || "open",
2942
+ action: "archive"
2943
+ });
2944
+ const fileName = (found.path || computeTicketPath(found)).split(/[/\\]/).pop();
2945
+ const result = archiveTicketEntry({ cwd: opts.cwd, ticketDir, indexJson, found, opts });
2946
+ if (opts.dryRun)
2947
+ return;
2948
+ writeTicketIndexJson(opts.cwd, indexJson, opts);
2949
+ syncActiveTicketId(opts.cwd);
2950
+ if (result?.id) {
2951
+ appendTelemetryEvent(opts.cwd, {
2952
+ event: "ticket_archived",
2953
+ action: "ticket-archive",
2954
+ ticket: result.id,
2955
+ file: result.path,
2956
+ status: "archived"
2957
+ });
2958
+ }
2959
+ return result;
2960
+ }
2961
+ export async function runTicketDiscard(opts) {
2962
+ applyTicketRootContext(opts);
2963
+ applyTicketContext(opts);
2964
+ const indexJson = loadTicketIndex(opts);
2965
+ if (!opts.latest && !opts.ticketId) {
2966
+ if (opts.nonInteractive) {
2967
+ throw new Error("ticket discard: --id or --latest is required in non-interactive mode.");
2968
+ }
2969
+ await withReadline(async (rl) => {
2970
+ const choices = indexJson.entries
2971
+ .filter(e => e.status === "open")
2972
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.id }));
2973
+ if (choices.length > 0) {
2974
+ opts.ticketId = await selectOne(rl, "Choose an unapproved ticket to discard:", choices);
2975
+ }
2976
+ else {
2977
+ throw new Error("No open tickets found to discard.");
2978
+ }
2979
+ });
2980
+ }
2981
+ let found = pickTicketEntry(opts, indexJson);
2982
+ // INDEX 미등록이어도 --id로 직접 파일을 찾아 폐기한다.
2983
+ let absPath;
2984
+ if (!found && opts.ticketId) {
2985
+ const ticketId = String(opts.ticketId);
2986
+ const ticketsRoot = detectConsumerTicketDir(opts.cwd);
2987
+ const groups = ticketsRoot && existsSync(ticketsRoot) ? readdirSync(ticketsRoot, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : [];
2988
+ for (const group of groups) {
2989
+ const candidate = join(ticketsRoot, group, `${ticketId}.md`);
2990
+ if (existsSync(candidate)) {
2991
+ absPath = candidate;
2992
+ break;
2993
+ }
2994
+ // partial match (id prefix in filename)
2995
+ if (!absPath && existsSync(ticketsRoot)) {
2996
+ const files = readdirSync(join(ticketsRoot, group)).filter(f => f.startsWith(ticketId) && f.endsWith(".md"));
2997
+ if (files.length === 1) {
2998
+ absPath = join(ticketsRoot, group, files[0]);
2999
+ break;
3000
+ }
3001
+ }
3002
+ }
3003
+ if (!absPath)
3004
+ throw new Error(`ticket discard: no matching entry for '${ticketId}'`);
3005
+ found = { id: ticketId, status: "open" };
3006
+ }
3007
+ else if (!found) {
3008
+ throw new Error("ticket discard: no matching entry");
3009
+ }
3010
+ else {
3011
+ absPath = resolveTicketEntryOrComputedPath(opts.cwd, found);
3012
+ }
3013
+ const relativePath = toRepoRelativePath(opts.cwd, absPath);
3014
+ if (!existsSync(absPath))
3015
+ throw new Error("Ticket file not found: " + relativePath);
3016
+ const body = readFileSync(absPath, "utf8");
3017
+ const { meta } = parseFrontMatter(body);
3018
+ assertTicketWorkflowAction(meta, {
3019
+ fallbackStatus: found.status || "open",
3020
+ action: "discard"
3021
+ });
3022
+ const phase = Number(meta.phase || 1);
3023
+ const status = String(meta.status || found.status || "open").toLowerCase();
3024
+ if (phase >= 2 || (status !== "open" && status !== "approval_required")) {
3025
+ throw new Error(`ticket discard: ${found.id} is not an unapproved Phase 1 ticket. Use ticket close/archive for approved or executed work.`);
3026
+ }
3027
+ if (opts.dryRun) {
3028
+ console.log(`ticket discard: would delete ${found.id} (${relativePath})`);
3029
+ return { id: found.id, path: relativePath, discarded: false };
3030
+ }
3031
+ rmSync(absPath, { force: true });
3032
+ const nextEntries = (indexJson.entries || []).filter(entry => entry.id !== found.id);
3033
+ writeTicketIndexJson(opts.cwd, {
3034
+ ...indexJson,
3035
+ entries: nextEntries
3036
+ }, opts);
3037
+ syncActiveTicketId(opts.cwd);
3038
+ appendTelemetryEvent(opts.cwd, {
3039
+ event: "ticket_discarded",
3040
+ action: "ticket-discard",
3041
+ ticket: found.id,
3042
+ file: relativePath,
3043
+ phase,
3044
+ status: "discarded"
3045
+ });
3046
+ console.log(`ticket: discarded -> ${found.id}`);
3047
+ return { id: found.id, path: relativePath, discarded: true };
3048
+ }
3049
+ // #727: ticket reports / report attach 제거 — report는 cwd(.deuk/docs/plan)에 쓰던
3050
+ // 의미없는 레거시 기록이다(언니 지시). cwd write를 없애기 위해 두 명령을 통째로 제거.
3051
+ export async function runTicketSanitize(opts) {
3052
+ applyTicketRootContext(opts);
3053
+ const dir = detectConsumerTicketDir(opts.cwd);
3054
+ if (!dir) {
3055
+ console.log("No ticket directory found.");
3056
+ return;
3057
+ }
3058
+ const files = collectTicketMarkdownFiles(dir);
3059
+ let fixed = 0, skipped = 0;
3060
+ for (const absPath of files) {
3061
+ const raw = readFileSync(absPath, "utf8");
3062
+ const { meta, content } = parseFrontMatter(raw);
3063
+ const stripped = stripLeadingFrontMatter(content);
3064
+ if (stripped === content) {
3065
+ skipped++;
3066
+ continue;
3067
+ }
3068
+ const next = stringifyFrontMatter(meta, stripped);
3069
+ if (!opts.dryRun)
3070
+ writeFileLF(absPath, next);
3071
+ console.log(`[SANITIZE] ${toRepoRelativePath(opts.cwd, absPath)}`);
3072
+ fixed++;
3073
+ }
3074
+ console.log(`Done: ${fixed} fixed, ${skipped} already clean.`);
3075
+ }
3076
+ // A ticket file is "broken" when its leading frontmatter is a migration stub:
3077
+ // id/title/summary all equal the filename slug and it carries `tags: migrated`,
3078
+ // typically followed by a second frontmatter block holding the real ticket. Such
3079
+ // archive copies are migration artifacts, never the source of truth.
3080
+ function isBrokenMigratedStub(absPath) {
3081
+ try {
3082
+ const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
3083
+ const slug = absPath.split(/[/\\]/).pop().replace(/\.md$/i, "");
3084
+ const title = String(meta.title || "").trim();
3085
+ const tags = meta.tags;
3086
+ const migrated = tags === "migrated" || (Array.isArray(tags) && tags.includes("migrated"));
3087
+ return migrated && title === slug;
3088
+ }
3089
+ catch {
3090
+ return false;
3091
+ }
3092
+ }
3093
+ // A live ticket is a trustworthy source of truth when its leading frontmatter is
3094
+ // clean: a single frontmatter block (no double-doc) and a title that is NOT just
3095
+ // the filename slug.
3096
+ function isCleanLiveTicket(absPath) {
3097
+ try {
3098
+ const raw = readFileSync(absPath, "utf8");
3099
+ const frontmatterBlocks = (raw.match(/^---\s*$/gm) || []).length;
3100
+ if (frontmatterBlocks > 2)
3101
+ return false; // more than one frontmatter block
3102
+ const { meta } = parseFrontMatter(raw);
3103
+ const slug = absPath.split(/[/\\]/).pop().replace(/\.md$/i, "");
3104
+ const title = String(meta.title || "").trim();
3105
+ return title.length > 0 && title !== slug;
3106
+ }
3107
+ catch {
3108
+ return false;
3109
+ }
3110
+ }
3111
+ // `ticket doctor`: detect tickets whose same filename exists in both a live dir
3112
+ // (main/sub) and an archive dir, and remove the redundant ARCHIVE copy — but only
3113
+ // when the live copy is a trustworthy source of truth. Default is dry-run; pass
3114
+ // --apply to actually delete. Never deletes when the live copy is missing/broken.
3115
+ export async function runTicketDoctor(opts) {
3116
+ applyTicketRootContext(opts);
3117
+ const dir = detectConsumerTicketDir(opts.cwd);
3118
+ if (!dir) {
3119
+ console.log("No ticket directory found.");
3120
+ return;
3121
+ }
3122
+ const files = collectTicketMarkdownFiles(dir);
3123
+ // Group by filename; classify each path as live (not under archive/) or archived.
3124
+ const byName = new Map();
3125
+ for (const absPath of files) {
3126
+ const name = absPath.split(/[/\\]/).pop();
3127
+ const rel = toRepoRelativePath(opts.cwd, absPath);
3128
+ const isArchive = /[/\\]archive[/\\]/.test(absPath) || /[/\\]archive[/\\]?$/.test(absPath);
3129
+ const bucket = byName.get(name) || { live: [], archive: [] };
3130
+ (isArchive ? bucket.archive : bucket.live).push(absPath);
3131
+ byName.set(name, bucket);
3132
+ }
3133
+ const apply = Boolean(opts.apply);
3134
+ let removed = 0, warned = 0, skipped = 0;
3135
+ for (const [name, bucket] of byName) {
3136
+ if (bucket.archive.length === 0)
3137
+ continue; // nothing archived
3138
+ if (bucket.live.length === 0) { // archive-only: not a duplicate
3139
+ skipped++;
3140
+ continue;
3141
+ }
3142
+ const liveOk = bucket.live.some(p => isCleanLiveTicket(p));
3143
+ if (!liveOk) {
3144
+ console.log(`[WARN] ${name}: live copy missing or broken — leaving archive copy untouched.`);
3145
+ warned++;
3146
+ continue;
3147
+ }
3148
+ for (const archAbs of bucket.archive) {
3149
+ const archRel = toRepoRelativePath(opts.cwd, archAbs);
3150
+ const broken = isBrokenMigratedStub(archAbs);
3151
+ const tag = broken ? "broken-migrated-stub" : "duplicate-of-live";
3152
+ if (apply) {
3153
+ unlinkSync(archAbs);
3154
+ console.log(`[REMOVE] ${archRel} (${tag})`);
3155
+ }
3156
+ else {
3157
+ console.log(`[DRY-RUN] would remove ${archRel} (${tag})`);
3158
+ }
3159
+ removed++;
3160
+ }
3161
+ }
3162
+ const verb = apply ? "removed" : "would remove";
3163
+ console.log(`Done: ${removed} ${verb}, ${warned} warned (live missing/broken), ${skipped} archive-only skipped.`);
3164
+ if (!apply && removed > 0)
3165
+ console.log("Re-run with --apply to delete the archive copies above.");
3166
+ }
3167
+ export async function runTicketRebuild(opts) {
3168
+ applyTicketRootContext(opts, { createIfMissing: true });
3169
+ console.log("Rebuilding INDEX.json from markdown files...");
3170
+ loadTicketIndex(opts, { force: true, rebuild: true });
3171
+ }
3172
+ export async function runTicketMove(opts) {
3173
+ // #635/#633: --workspace alone no longer means "move to another workspace" — since
3174
+ // #633 every ticket command is expected to carry --workspace to SELECT the target
3175
+ // workspace. Only treat this as a cross-workspace move when the requested workspace
3176
+ // actually differs from the ticket's current one; otherwise fall through to the
3177
+ // normal phase move below. Detecting this requires resolving both cwds first.
3178
+ if (opts.workspace) {
3179
+ const sourceCwd = opts.cwd || process.cwd();
3180
+ applyTicketRootContext({ ...opts, workspace: undefined }, {});
3181
+ const sourceOpts = { ...opts, workspace: undefined };
3182
+ applyTicketRootContext(sourceOpts, {});
3183
+ applyTicketContext(sourceOpts);
3184
+ if (!sourceOpts.ticketId && !sourceOpts.latest)
3185
+ sourceOpts.latest = true;
3186
+ const targetOpts = { ...opts };
3187
+ applyTicketRootContext(targetOpts, {});
3188
+ // Same workspace → this is a phase move, not a relocation. Drop the workspace
3189
+ // hint and continue to the phase-move path instead of erroring out.
3190
+ if (targetOpts.cwd !== sourceOpts.cwd) {
3191
+ const sourceIndex = loadTicketIndex(sourceOpts);
3192
+ const sourceEntry = pickTicketEntry(sourceOpts, sourceIndex);
3193
+ if (!sourceEntry)
3194
+ throw new Error("No matching ticket found to move.");
3195
+ const { absPath: sourceAbs, meta: sourceMeta, content: sourceContent, docmeta: sourceDocMeta } = readTicketDocument(sourceOpts.cwd, sourceEntry, { action: "ticket move workspace", requireExists: true });
3196
+ const targetCwd = targetOpts.cwd;
3197
+ // breadcrumb 갱신
3198
+ const targetBreadcrumb = resolveWorkspaceContext(targetCwd).breadcrumb;
3199
+ sourceMeta.breadcrumb = targetBreadcrumb;
3200
+ // 대상 경로 계산 및 파일 복사
3201
+ const targetRelPath = computeTicketPath({ ...sourceEntry, fileName: (sourceEntry.path || computeTicketPath(sourceEntry)).split(/[/\\]/).pop() });
3202
+ const targetAbs = makePath(targetCwd, targetRelPath);
3203
+ mkdirSync(dirname(targetAbs), { recursive: true });
3204
+ const newBody = renderTicketDocument({ meta: sourceMeta, content: sourceContent, docmeta: sourceDocMeta });
3205
+ writeTicketMarkdownFile(targetAbs, newBody);
3206
+ // 소스 INDEX에서 제거
3207
+ const srcIndex = readTicketIndexJson(sourceOpts.cwd);
3208
+ srcIndex.entries = (srcIndex.entries || []).filter(e => e.id !== sourceEntry.id);
3209
+ writeTicketIndexJson(sourceOpts.cwd, srcIndex);
3210
+ rmSync(sourceAbs, { force: true });
3211
+ syncActiveTicketId(sourceOpts.cwd);
3212
+ // 대상 INDEX에 추가
3213
+ const dstIndex = readTicketIndexJson(targetCwd);
3214
+ dstIndex.entries = [...(dstIndex.entries || []), { ...sourceEntry, workspace: targetBreadcrumb }];
3215
+ writeTicketIndexJson(targetCwd, dstIndex);
3216
+ syncActiveTicketId(targetCwd);
3217
+ console.log(`ticket: moved workspace -> ${sourceEntry.id} (${sourceMeta.breadcrumb || targetBreadcrumb})`);
3218
+ appendTelemetryEvent(targetCwd, { event: "ticket_workspace_moved", action: "ticket-move-workspace", ticket: sourceEntry.id, from: sourceOpts.cwd, to: targetCwd });
3219
+ return;
3220
+ }
3221
+ // Same workspace: fall through to the phase-move path below. Resolve cwd from
3222
+ // the (already validated) workspace and drop the now-handled workspace hint.
3223
+ opts.cwd = targetOpts.cwd;
3224
+ opts.workspace = undefined;
3225
+ }
3226
+ applyTicketRootContext(opts);
3227
+ applyTicketContext(opts);
3228
+ if (!opts.ticketId && !opts.latest) {
3229
+ if (opts.nonInteractive) {
3230
+ throw new Error("ticket move: --id or --latest is required in non-interactive mode.");
3231
+ }
3232
+ opts.latest = true; // Default to latest
3233
+ }
3234
+ const index = loadTicketIndex(opts);
3235
+ const entry = pickTicketEntry(opts, index);
3236
+ if (!entry)
3237
+ throw new Error("No matching ticket found to move.");
3238
+ const { absPath: abs, meta: parsedMeta, content, docmeta: existingDocMeta } = readTicketDocument(opts.cwd, entry, { action: "ticket move", requireExists: true });
3239
+ const previousIndex = readTicketIndexJson(opts.cwd);
3240
+ const body = readFileSync(abs, "utf8");
3241
+ const currentPhase = Number(parsedMeta.phase || 1);
3242
+ const currentStatus = String(parsedMeta.status || entry.status || "open").toLowerCase();
3243
+ const requestedPhase = Number(opts.phase);
3244
+ // #635: if the user explicitly asked for a phase (--phase/--to) but it didn't
3245
+ // parse to a number, DO NOT silently advance to the next phase — that hides the
3246
+ // user's intent and moves the ticket the wrong way. Fail loudly instead.
3247
+ if (opts.phaseRequested && !Number.isFinite(requestedPhase)) {
3248
+ throw new Error(`ticket move: could not parse the requested phase from --phase/--to "${opts.phase}". ` +
3249
+ `Use a phase number, e.g. --phase 2 (or --to 2). Refusing to auto-advance.`);
3250
+ }
3251
+ let transition;
3252
+ try {
3253
+ transition = resolveTicketWorkflowCommandTransition(opts.cwd, entry, abs, parsedMeta, content, opts, {
3254
+ action: "move",
3255
+ phase: Number.isFinite(requestedPhase) ? requestedPhase : undefined,
3256
+ next: opts.next || !Number.isFinite(requestedPhase)
3257
+ });
3258
+ }
3259
+ catch (err) {
3260
+ const reasonText = String(err.message || "");
3261
+ if (currentPhase === 1 && (requestedPhase >= 2 || opts.next || !Number.isFinite(requestedPhase))) {
3262
+ const reasons = reasonText.split(":").pop().split(",").map(value => value.trim()).filter(Boolean);
3263
+ throw new Error(formatTicketWorkflowGateFailure(opts.cwd, abs, entry, parsedMeta, content, opts, "phase2", reasons));
3264
+ }
3265
+ throw err;
3266
+ }
3267
+ const nextPhase = transition.phase;
3268
+ const ticketFileName = (entry.path || computeTicketPath(entry)).split(/[/\\]/).pop();
3269
+ const reopenTargetRelPath = nextPhase <= 1
3270
+ ? computeTicketPath({
3271
+ ...entry,
3272
+ fileName: ticketFileName,
3273
+ status: "open",
3274
+ archiveYearMonth: undefined
3275
+ })
3276
+ : null;
3277
+ const reopenTargetAbsPath = reopenTargetRelPath ? makePath(opts.cwd, reopenTargetRelPath) : abs;
3278
+ if (currentPhase === 1 && nextPhase >= 2) {
3279
+ const reasons = getPhase1IncompleteReasons(opts.cwd, abs);
3280
+ if (reasons.length) {
3281
+ throw new Error(`[VALIDATION FAILED] Ticket ${entry.id} has incomplete Phase 1 planning evidence: ${reasons.join(", ")}. Fill substantive APC and compact plan content before moving to Phase 2.`);
3282
+ }
3283
+ if (!hasExplicitExecutionApproval(opts)) {
3284
+ throw new Error(`[APPROVAL REQUIRED] Ticket ${entry.id} cannot move from Phase 1 to Phase 2 without explicit review approval. Re-run with --workflow execute or --approval approved after the plan is reviewed.`);
3285
+ }
3286
+ }
3287
+ const transitionDocMeta = transition.from === transition.to
3288
+ ? existingDocMeta
3289
+ : collectTicketTransitionValidationDocMeta(opts.cwd, abs, parsedMeta, content, opts, transition.to);
3290
+ parsedMeta.phase = nextPhase;
3291
+ parsedMeta.status = transition.status;
3292
+ const nextMeta = {
3293
+ ...clearDocMetaFrontmatterSummary(parsedMeta),
3294
+ ...buildDocMetaFrontmatterSummary(transitionDocMeta)
3295
+ };
3296
+ for (const key of Object.keys(parsedMeta))
3297
+ delete parsedMeta[key];
3298
+ Object.assign(parsedMeta, nextMeta);
3299
+ const newBody = renderTicketDocument({ meta: parsedMeta, content, docmeta: transitionDocMeta });
3300
+ let finalAbsPath = abs;
3301
+ let reopenedFromArchive = false;
3302
+ try {
3303
+ writeTicketMarkdownFile(abs, newBody);
3304
+ // Re-sync index to reflect the status change if any
3305
+ opts.ticketId = entry.id;
3306
+ if (parsedMeta.status !== entry.status) {
3307
+ opts.status = parsedMeta.status;
3308
+ updateTicketEntryStatus(opts.cwd, opts);
3309
+ }
3310
+ else {
3311
+ loadTicketIndex(opts, { force: true });
3312
+ }
3313
+ if (nextPhase <= 1 && reopenTargetAbsPath !== abs) {
3314
+ if (existsSync(reopenTargetAbsPath)) {
3315
+ throw new Error("ticket move: reopen target already exists " + toRepoRelativePath(opts.cwd, reopenTargetAbsPath));
3316
+ }
3317
+ mkdirSync(dirname(reopenTargetAbsPath), { recursive: true });
3318
+ writeTicketMarkdownFile(reopenTargetAbsPath, newBody);
3319
+ rmSync(abs);
3320
+ finalAbsPath = reopenTargetAbsPath;
3321
+ reopenedFromArchive = true;
3322
+ loadTicketIndex(opts, { force: true });
3323
+ }
3324
+ runTicketWorkflowQualityGate(opts.cwd, {
3325
+ ticketAbsPath: finalAbsPath,
3326
+ context: `ticket move ${entry.id}`
3327
+ });
3328
+ syncActiveTicketId(opts.cwd);
3329
+ appendTelemetryEvent(opts.cwd, {
3330
+ event: "ticket_phase_moved",
3331
+ action: "ticket-move",
3332
+ ticket: entry.id,
3333
+ file: toWsRelativePath(opts.cwd, finalAbsPath),
3334
+ phase: nextPhase,
3335
+ status: parsedMeta.status,
3336
+ from: transition.from,
3337
+ to: transition.to
3338
+ });
3339
+ console.log(`ticket: moved -> ${entry.id} is now in Phase ${nextPhase} (${parsedMeta.status})`);
3340
+ // Represent the resulting state (DocMeta state / required slots / next action),
3341
+ // not just the one-line transition confirmation. Isolated so a surface render
3342
+ // hiccup never triggers the move rollback below.
3343
+ try {
3344
+ await runTicketStatus({ ...opts, ticketId: entry.id, statusDetail: true, json: false });
3345
+ }
3346
+ catch {
3347
+ printTicketPhaseLine(entry.id, finalAbsPath, nextPhase, opts);
3348
+ printUsageReminder(opts.cwd, opts);
3349
+ }
3350
+ // 이동 후 다음 페이즈로 진행하는 가이드를 함께 출력한다. runTicketStatus 경로는
3351
+ // DocMeta 상태/슬롯만 렌더하고 buildPhaseAdvanceHint를 거치지 않아, 이게 없으면
3352
+ // "페이즈 이동 시 다음 단계 가이드"가 표시되지 않는다. 렌더 실패는 무시한다.
3353
+ try {
3354
+ const hint = buildPhaseAdvanceHint(entry.id, nextPhase, parsedMeta.status, resolveDocsLanguage(opts.docsLanguage || "auto"));
3355
+ if (hint)
3356
+ console.log(hint);
3357
+ }
3358
+ catch { /* guidance is best-effort */ }
3359
+ }
3360
+ catch (err) {
3361
+ if (reopenedFromArchive) {
3362
+ rmSync(finalAbsPath, { force: true });
3363
+ writeTicketMarkdownFile(abs, body);
3364
+ }
3365
+ rollbackTicketWorkflowArtifacts(opts.cwd, previousIndex, body, abs, opts);
3366
+ throw err;
3367
+ }
3368
+ }
3369
+ export async function runTicketNext(opts) {
3370
+ applyTicketRootContext(opts);
3371
+ const index = loadTicketIndex(opts);
3372
+ // Find the first active ticket, or if none, the first open ticket (earliest created)
3373
+ const rows = filterTicketEntries(index.entries, opts)
3374
+ .filter(entry => isTicketNextRunnableCandidate(opts.cwd, entry))
3375
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
3376
+ // Prefer the focus pointer (activeTicketId), then any live ticket. "active" status is legacy-only.
3377
+ let found = rows.find(e => e.id === index.activeTicketId);
3378
+ if (!found) {
3379
+ found = rows.find(e => e.status === "active" || e.status === "open");
3380
+ }
3381
+ if (!found) {
3382
+ const latestClosed = filterTicketEntries(index.entries, opts)
3383
+ .filter(entry => String(entry.status || "").toLowerCase() === "closed")
3384
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0];
3385
+ if (latestClosed) {
3386
+ const latestClosedPath = resolveTicketEntryOrComputedPath(opts.cwd, latestClosed);
3387
+ if (existsSync(latestClosedPath)) {
3388
+ const { content } = readTicketDocument(opts.cwd, latestClosed, { action: "ticket next", computePath: true });
3389
+ if (followUpDecisionMeansNoFollowUp(content)) {
3390
+ const absPath = toPosixPath(latestClosedPath);
3391
+ if (opts.pathOnly) {
3392
+ console.log(`no-follow-up:${latestClosed.id}`);
3393
+ }
3394
+ else if (isCompactTicketOutput(opts)) {
3395
+ console.log(`no-follow-up:${latestClosed.id}`);
3396
+ }
3397
+ else {
3398
+ const posixPath = toWsRelativePath(opts.cwd, latestClosedPath);
3399
+ console.log(`No follow-up required after ${latestClosed.id}`);
3400
+ console.log(`Path: [${posixPath}](${posixPath})`);
3401
+ }
3402
+ return { status: "no-follow-up", ticket: latestClosed.id, path: latestClosed.path || computeTicketPath(latestClosed) };
3403
+ }
3404
+ }
3405
+ }
3406
+ throw new Error("No active or open tickets found to proceed with. Inspect recent git history before creating a follow-up ticket.");
3407
+ }
3408
+ if (index.activeTicketId !== found.id) {
3409
+ writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
3410
+ }
3411
+ const foundAbs = readTicketDocument(opts.cwd, found, { action: "ticket next", requireExists: true });
3412
+ const absPath = toPosixPath(foundAbs.absPath);
3413
+ if (isCompactTicketOutput(opts) || opts.pathOnly) {
3414
+ printTicketSelectionLine(found.id, absPath, { ...opts, docsLanguage: foundAbs.meta.docsLanguage });
3415
+ }
3416
+ else {
3417
+ const posixPath = toWsRelativePath(opts.cwd, foundAbs.absPath);
3418
+ console.log(`Next ticket: ${found.id}`);
3419
+ console.log(`Path: [${posixPath}](${posixPath})`);
3420
+ if (opts.printContent)
3421
+ console.log("\n" + readFileSync(foundAbs.absPath, "utf8"));
3422
+ }
3423
+ }
3424
+ function isTicketNextRunnableCandidate(cwd, entry) {
3425
+ const ticketDoc = readTicketDocument(cwd, entry, { action: "ticket next", computePath: true, requireExists: false });
3426
+ if (!ticketDoc.exists)
3427
+ return true;
3428
+ const { meta } = ticketDoc;
3429
+ const workflowSource = String(meta.workflowSource || meta.ticketWorkflowSource || "").trim();
3430
+ return workflowSource === "ticket-create";
3431
+ }
3432
+ export async function runTicketHotfix(opts) {
3433
+ applyTicketRootContext(opts);
3434
+ if (!opts.ticketId && !opts.latest) {
3435
+ if (opts.nonInteractive) {
3436
+ throw new Error("ticket hotfix: --id or --latest is required in non-interactive mode.");
3437
+ }
3438
+ opts.latest = true;
3439
+ }
3440
+ if (!opts.reason) {
3441
+ throw new Error("[HOTFIX DENIED] A mandatory --reason must be provided to justify bypassing standard rules (e.g., 'codegen is broken').");
3442
+ }
3443
+ // User explicit approval
3444
+ if (!opts.nonInteractive) {
3445
+ let proceed = false;
3446
+ await withReadline(async (rl) => {
3447
+ proceed = await new Promise(resolve => {
3448
+ rl.question(`\n⚠️ [EMERGENCY HOTFIX] This will bypass standard APC rules.\nReason: ${opts.reason}\nProceed? (y/N): `, a => {
3449
+ resolve(a.trim().toLowerCase() === 'y');
3450
+ });
3451
+ });
3452
+ });
3453
+ if (!proceed) {
3454
+ console.log('Hotfix cancelled by user.');
3455
+ return;
3456
+ }
3457
+ }
3458
+ const index = loadTicketIndex(opts);
3459
+ const entry = pickTicketEntry(opts, index);
3460
+ if (!entry)
3461
+ throw new Error("No matching ticket found for hotfix.");
3462
+ const { absPath: abs, meta, content, docmeta } = readTicketDocument(opts.cwd, entry, { action: "ticket hotfix", requireExists: true });
3463
+ // Force phase 2, bypassing APC checks. Focus is set via activeTicketId below,
3464
+ // not via a status value ("active" status was removed).
3465
+ meta.phase = 2;
3466
+ meta.status = "open";
3467
+ meta.hotfix = true;
3468
+ meta.hotfixReason = opts.reason;
3469
+ // Append hotfix record to content
3470
+ const timestamp = new Date().toISOString();
3471
+ const hotfixRecord = `\n\n> [!WARNING]\n> **EMERGENCY HOTFIX ACTIVATED** (${timestamp})\n> **Reason:** ${opts.reason}\n> Standard APC and Phase 1 guards were bypassed.\n`;
3472
+ writeTicketDocument(abs, { meta, content: content + hotfixRecord, docmeta });
3473
+ // Re-sync index and make this ticket the single focus (activeTicketId).
3474
+ opts.ticketId = entry.id;
3475
+ opts.status = "open";
3476
+ updateTicketEntryStatus(opts.cwd, opts);
3477
+ const hotfixIndex = loadTicketIndex(opts);
3478
+ writeTicketIndexJson(opts.cwd, { ...hotfixIndex, activeTicketId: entry.id });
3479
+ syncActiveTicketId(opts.cwd);
3480
+ console.log(`[EMERGENCY HOTFIX] Ticket ${entry.id} is now ACTIVE. Rule policy bypassed for this session.`);
3481
+ // Auto-create derivation ticket
3482
+ const deriveTopic = `codegen-fix-${entry.id}`;
3483
+ const deriveSummary = `[DERIVED] Fix CodeGen source for hotfix: ${opts.reason}`;
3484
+ console.log(`[HOTFIX] Auto-creating derivation ticket: ${deriveTopic}`);
3485
+ try {
3486
+ await runTicketCreate({
3487
+ cwd: opts.cwd,
3488
+ title: deriveTopic,
3489
+ summary: deriveSummary,
3490
+ chain: true,
3491
+ group: 'sub',
3492
+ parent: entry.id,
3493
+ tags: 'hotfix-derived,codegen',
3494
+ priority: 'P1',
3495
+ skipPhase0: true,
3496
+ nonInteractive: true
3497
+ });
3498
+ }
3499
+ catch (err) {
3500
+ console.warn(`[WARNING] Failed to auto-create derivation ticket: ${err.message}`);
3501
+ }
3502
+ }
3503
+ // #622: explicit, user-facing counterpart to the per-command auto-reconcile.
3504
+ // Migrates the current workspace's tickets to ~/.deuk-agent/tickets/ and reports
3505
+ // what happened. The heavy lifting (marker, registry, absorb, gitignore) is the
3506
+ // same reconcile path commands already trigger, so this is idempotent and safe.
3507
+ export async function runTicketMigrateHome(opts) {
3508
+ applyTicketRootContext(opts);
3509
+ const { resolveRealWorkspaceRoot } = await import("./cli-utils.js");
3510
+ // #626: resolve a REAL workspace (marker-owned ancestor or a project-boundary cwd).
3511
+ // No `|| opts.cwd` fallback — a non-workspace cwd must not be minted into a ghost.
3512
+ const root = resolveRealWorkspaceRoot(opts.cwd, opts);
3513
+ if (!root) {
3514
+ console.log("[migrate] cwd is not a workspace (no .deuk marker or project boundary) — nothing to migrate.");
3515
+ return;
3516
+ }
3517
+ const home = await import("./cli-ticket-home.js");
3518
+ const r = home.reconcileWorkspace(root, opts);
3519
+ if (r.skipped) {
3520
+ console.log("[migrate] cwd is the home ticket store itself — nothing to migrate.");
3521
+ return;
3522
+ }
3523
+ console.log(`[migrate] workspace: ${root}`);
3524
+ console.log(`[migrate] id: ${r.id}`);
3525
+ console.log(`[migrate] home dir: ${r.homeDir}`);
3526
+ console.log(`[migrate] absorbed legacy tickets: ${r.absorbed ? "yes" : "no (already migrated)"}`);
3527
+ console.log(`[migrate] registry ${r.pathChanged ? "updated (path changed)" : "already current"}`);
3528
+ console.log("[migrate] done — workspace ticket residue is now zero.");
3529
+ }
3530
+ //# sourceMappingURL=cli-ticket-commands.js.map