deuk-agent-flow 4.0.19

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 (59) hide show
  1. package/CHANGELOG.ko.md +223 -0
  2. package/CHANGELOG.md +227 -0
  3. package/LICENSE +184 -0
  4. package/README.ko.md +282 -0
  5. package/README.md +270 -0
  6. package/bin/deuk-agent-flow.js +50 -0
  7. package/bin/deuk-agent-rule.js +2 -0
  8. package/core-rules/AGENTS.md +153 -0
  9. package/core-rules/GEMINI.md +7 -0
  10. package/docs/architecture.ko.md +34 -0
  11. package/docs/architecture.md +33 -0
  12. package/docs/assets/architecture-v3.png +0 -0
  13. package/docs/how-it-works.ko.md +52 -0
  14. package/docs/how-it-works.md +71 -0
  15. package/docs/principles.ko.md +68 -0
  16. package/docs/principles.md +68 -0
  17. package/docs/usage-guide.ko.md +212 -0
  18. package/package.json +96 -0
  19. package/scripts/cli-args.mjs +200 -0
  20. package/scripts/cli-init-commands.mjs +1799 -0
  21. package/scripts/cli-init-logic.mjs +64 -0
  22. package/scripts/cli-prompts.mjs +104 -0
  23. package/scripts/cli-rule-compiler.mjs +112 -0
  24. package/scripts/cli-skill-commands.mjs +201 -0
  25. package/scripts/cli-telemetry-commands.mjs +599 -0
  26. package/scripts/cli-ticket-commands.mjs +2393 -0
  27. package/scripts/cli-ticket-index.mjs +298 -0
  28. package/scripts/cli-ticket-migration.mjs +320 -0
  29. package/scripts/cli-ticket-parser.mjs +209 -0
  30. package/scripts/cli-usage-commands.mjs +326 -0
  31. package/scripts/cli-utils.mjs +587 -0
  32. package/scripts/cli.mjs +246 -0
  33. package/scripts/lint-md.mjs +267 -0
  34. package/scripts/lint-rules.mjs +186 -0
  35. package/scripts/merge-logic.mjs +44 -0
  36. package/scripts/plan-parser.mjs +53 -0
  37. package/scripts/publish-dual-npm.mjs +141 -0
  38. package/scripts/smoke-npm-docker.mjs +102 -0
  39. package/scripts/smoke-npm-local.mjs +109 -0
  40. package/scripts/update-download-badge.mjs +107 -0
  41. package/templates/MODULE_RULE_TEMPLATE.md +11 -0
  42. package/templates/PROJECT_RULE.md +47 -0
  43. package/templates/TICKET_TEMPLATE.ko.md +44 -0
  44. package/templates/TICKET_TEMPLATE.md +44 -0
  45. package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +23 -0
  46. package/templates/project-pilot/DRIFT_CHECKLIST.md +19 -0
  47. package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -0
  48. package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -0
  49. package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -0
  50. package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -0
  51. package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -0
  52. package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -0
  53. package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -0
  54. package/templates/rules.d/deukcontext-mcp.md +31 -0
  55. package/templates/rules.d/platform-coexistence.md +29 -0
  56. package/templates/skills/context-recall/SKILL.md +25 -0
  57. package/templates/skills/generated-file-guard/SKILL.md +25 -0
  58. package/templates/skills/project-pilot/SKILL.md +63 -0
  59. package/templates/skills/safe-refactor/SKILL.md +25 -0
@@ -0,0 +1,2393 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, readdirSync, rmSync, statSync } from "fs";
2
+ import { hostname } from "os";
3
+ import { basename, join, dirname, relative, resolve } from "path";
4
+ import {
5
+ toSlug, toSnakeCaseKey, requireNonEmptySlug, toRepoRelativePath, toFileUri, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter,
6
+ resolveDocsLanguage, inferDocsLanguageFromText, normalizeDocsLanguage, isMcpActive, withReadline, parseFrontMatter,
7
+ AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_DIR_NAME, TICKET_INDEX_FILENAME, WORKFLOW_MODE_EXECUTE,
8
+ detectConsumerTicketDir, resolveConsumerTicketRoot, loadInitConfig, computeTicketPath, normalizeWorkflowMode
9
+ } from "./cli-utils.mjs";
10
+ import { readTicketIndexJson, writeTicketIndexJson, syncActiveTicketId, generateTicketId, syncToPipeline } from "./cli-ticket-index.mjs";
11
+ import { appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, updateTicketEntryStatus } from "./cli-ticket-parser.mjs";
12
+ import { appendInternalWorkflowEvent, buildTelemetrySummary, getTelemetryCompactSummary } from "./cli-telemetry-commands.mjs";
13
+ import { parsePlan } from "./plan-parser.mjs";
14
+ import { collectChangedFiles, lintMarkdownPaths } from "./lint-md.mjs";
15
+ import { auditRules } from "./lint-rules.mjs";
16
+ import { getUsageReminderLine } from "./cli-usage-commands.mjs";
17
+ import ejs from "ejs";
18
+ import YAML from "yaml";
19
+
20
+ import { createInterface } from "readline";
21
+ import { selectOne } from "./cli-prompts.mjs";
22
+
23
+ const MAX_OPEN_TICKETS = 20;
24
+ const OPEN_TICKET_STATUSES = new Set(["open", "active"]);
25
+ const AUTO_ARCHIVE_DONE_STATUSES = new Set(["closed", "cancelled", "wontfix"]);
26
+
27
+ async function ensurePhase0Validation(opts) {
28
+ if (!opts.evidence && !opts.skipPhase0) {
29
+ // No more interactive prompts. Default to skip if no evidence provided.
30
+ opts.skipPhase0 = true;
31
+ }
32
+
33
+ if (opts.skipPhase0) {
34
+ try {
35
+ if (!isCompactTicketOutput(opts) && await isMcpActive(opts.cwd)) {
36
+ console.warn("[WARNING] Phase 0 RAG evidence is recommended when the MCP server is active. Proceeding without evidence as requested.");
37
+ }
38
+ } catch (err) {
39
+ // MCP detection failure should not block ticket creation
40
+ if (process.env.DEBUG) console.warn("[DEBUG] MCP activation check failed:", err.message);
41
+ }
42
+ }
43
+ }
44
+
45
+ function resolveTicketDocsLanguage(cwd, docsLanguageInput, promptText = "") {
46
+ const config = loadInitConfig(cwd) || {};
47
+ const explicitDocsLanguage = normalizeDocsLanguage(docsLanguageInput);
48
+ const configDocsLanguage = normalizeDocsLanguage(config.docsLanguage || "auto");
49
+ const promptDocsLanguage = explicitDocsLanguage === "auto" && configDocsLanguage === "auto"
50
+ ? inferDocsLanguageFromText(promptText)
51
+ : null;
52
+ const docsLanguage = resolveDocsLanguage(
53
+ explicitDocsLanguage !== "auto"
54
+ ? explicitDocsLanguage
55
+ : configDocsLanguage !== "auto"
56
+ ? configDocsLanguage
57
+ : promptDocsLanguage || "en"
58
+ );
59
+ return docsLanguage;
60
+ }
61
+
62
+ function hasPlaceholderTokens(text) {
63
+ const src = String(text || "").toLowerCase();
64
+ return src.includes("[add ") || src.includes("[fill") || src.includes("placeholder") || src.includes("todo") || src.includes("tbd");
65
+ }
66
+
67
+ const ANALYSIS_DESIGN_SECTION_REQUIREMENTS = [
68
+ {
69
+ section: "Problem Analysis",
70
+ reason: "problem_analysis_missing",
71
+ scaffolds: [
72
+ "For investigation, regression, quality, or root-cause tickets, record the current analysis here before asking the user for clarification.",
73
+ "Chat should point back to this ticket after the analysis is recorded."
74
+ ]
75
+ },
76
+ {
77
+ section: "Improvement Direction",
78
+ reason: "improvement_direction_missing",
79
+ scaffolds: [
80
+ "Record the proposed fix direction or follow-up design path."
81
+ ]
82
+ }
83
+ ];
84
+
85
+ const REQUIRED_PHASE1_SECTIONS = [
86
+ "Agent Permission Contract (APC)",
87
+ "Compact Plan",
88
+ "Problem Analysis",
89
+ "Source Observations",
90
+ "Cause Hypotheses",
91
+ "Improvement Direction",
92
+ "Audit Evidence"
93
+ ];
94
+
95
+ const REQUIRED_APC_MARKERS = [
96
+ { name: "boundary", marker: "[BOUNDARY]" },
97
+ { name: "contract", marker: "[CONTRACT]" },
98
+ { name: "patch_plan", marker: "[PATCH PLAN]" }
99
+ ];
100
+ const REQUIRED_PHASE1_DATA_SECTIONS = [
101
+ "Compact Plan",
102
+ "Problem Analysis",
103
+ "Source Observations",
104
+ "Cause Hypotheses",
105
+ "Improvement Direction",
106
+ "Audit Evidence"
107
+ ];
108
+
109
+ const FOLLOW_UP_DECISION_NO_FOLLOW_UP_PATTERNS = [
110
+ /\bno[- ]follow[- ]up\b/i,
111
+ /\bfollow[- ]up\s*:\s*none\b/i,
112
+ /\bno further action\b/i,
113
+ /\bnone required\b/i,
114
+ /후속(?:\s+작업)?\s*(?:없음|불필요)/,
115
+ /추가(?:\s+작업)?\s*(?:없음|불필요)/
116
+ ];
117
+
118
+ const CLAIM_STOP_WORDS = new Set([
119
+ "the",
120
+ "and",
121
+ "or",
122
+ "is",
123
+ "are",
124
+ "was",
125
+ "were",
126
+ "be",
127
+ "this",
128
+ "that",
129
+ "with",
130
+ "for",
131
+ "from",
132
+ "in",
133
+ "on",
134
+ "of",
135
+ "to",
136
+ "it",
137
+ "ticket",
138
+ "issue",
139
+ "problem",
140
+ "analysis",
141
+ "failed",
142
+ "failure",
143
+ "record",
144
+ "recorded",
145
+ "claim",
146
+ "claiming",
147
+ "result",
148
+ "resulted",
149
+ "caused",
150
+ "causing",
151
+ "this",
152
+ "그",
153
+ "이",
154
+ "이슈",
155
+ "문제",
156
+ "실패",
157
+ "원인",
158
+ "분석",
159
+ "기록",
160
+ "티켓"
161
+ ]);
162
+
163
+ function tokenizeClaimText(text) {
164
+ const raw = String(text || "").toLowerCase();
165
+ const tokens = raw.match(/[0-9a-z가-힣_.-]+/g) || [];
166
+ const filtered = tokens
167
+ .filter(t => t.length > 2)
168
+ .filter(t => !CLAIM_STOP_WORDS.has(t));
169
+ return [...new Set(filtered)];
170
+ }
171
+
172
+ function collectClaimTargetSections(content) {
173
+ const targetSections = ["Problem Analysis", "Source Observations", "Cause Hypotheses", "Improvement Direction"];
174
+ return targetSections.map(name => extractMarkdownSection(content, name)).join(" ");
175
+ }
176
+
177
+ function parseMarkdownH2Sections(content) {
178
+ const lines = String(content || "").split("\n");
179
+ const sections = new Map();
180
+ let currentHeading = "";
181
+ let buffer = [];
182
+
183
+ const flush = () => {
184
+ if (!currentHeading) return;
185
+ sections.set(currentHeading, buffer.join("\n").trim());
186
+ };
187
+
188
+ for (const line of lines) {
189
+ const headingMatch = line.match(/^##\s+(.+?)\s*$/);
190
+ if (headingMatch) {
191
+ flush();
192
+ currentHeading = headingMatch[1].trim();
193
+ buffer = [];
194
+ continue;
195
+ }
196
+ if (currentHeading) {
197
+ buffer.push(line);
198
+ }
199
+ }
200
+
201
+ flush();
202
+ return sections;
203
+ }
204
+
205
+ function buildClaimCoverageSummary(claimTerms, sectionText) {
206
+ const haystack = String(sectionText || "").toLowerCase();
207
+ const normalized = haystack.replace(/\s+/g, " ");
208
+ const matched = claimTerms.filter(term => normalized.includes(term));
209
+ const missRate = claimTerms.length === 0 ? 0 : 1 - matched.length / claimTerms.length;
210
+ return {
211
+ matched,
212
+ missRate,
213
+ total: claimTerms.length
214
+ };
215
+ }
216
+
217
+ function extractMarkdownSection(content, heading) {
218
+ return parseMarkdownH2Sections(content).get(String(heading || "").trim()) || "";
219
+ }
220
+
221
+ function extractMarkdownSectionByAliases(content, aliases) {
222
+ const sections = parseMarkdownH2Sections(content);
223
+ for (const alias of aliases) {
224
+ const value = sections.get(String(alias || "").trim());
225
+ if (value !== undefined) return value;
226
+ }
227
+ return "";
228
+ }
229
+
230
+ function hasSubstantiveSectionContent(text, scaffolds = []) {
231
+ const src = String(text || "").trim();
232
+ if (!src || hasPlaceholderTokens(src)) return false;
233
+ const normalized = src.replace(/\s+/g, " ");
234
+ return !scaffolds.some(phrase => normalized.includes(phrase));
235
+ }
236
+
237
+ function getMarkerBody(text, marker, nextMarkers) {
238
+ const lines = String(text || "").split("\n");
239
+ const start = lines.findIndex(line => line.includes(marker));
240
+ if (start < 0) return null;
241
+
242
+ const body = [];
243
+ for (const line of lines.slice(start + 1)) {
244
+ if (nextMarkers.some(nextMarker => line.includes(nextMarker))) break;
245
+ body.push(line);
246
+ }
247
+ return body.join("\n").trim();
248
+ }
249
+
250
+ function getMissingApcFields(text) {
251
+ return REQUIRED_APC_MARKERS
252
+ .filter(({ marker }, index) => {
253
+ const nextMarkers = REQUIRED_APC_MARKERS.slice(index + 1).map(item => item.marker);
254
+ return !hasSubstantiveSectionContent(getMarkerBody(text, marker, nextMarkers), []);
255
+ })
256
+ .map(field => field.name);
257
+ }
258
+
259
+ function getPhase1PlanBodyReasons(body) {
260
+ const content = String(body || "");
261
+ const sections = parseMarkdownH2Sections(content);
262
+ const apcSection = extractMarkdownSectionByAliases(content, [
263
+ "Agent Permission Contract (APC)",
264
+ "Agent Permission Contract",
265
+ "APC"
266
+ ]);
267
+ const reasons = [];
268
+
269
+ if (!apcSection) {
270
+ reasons.push("missing_apc_block");
271
+ } else {
272
+ for (const field of getMissingApcFields(apcSection)) {
273
+ reasons.push(`apc_${field}_missing`);
274
+ }
275
+ }
276
+
277
+ for (const sectionName of REQUIRED_PHASE1_DATA_SECTIONS) {
278
+ const sectionKey = toSnakeCaseKey(sectionName);
279
+ if (!sections.has(sectionName)) {
280
+ reasons.push(`${sectionKey}_missing`);
281
+ continue;
282
+ }
283
+ if (!hasSubstantiveSectionContent(sections.get(sectionName), [])) {
284
+ reasons.push(`${sectionKey}_incomplete`);
285
+ }
286
+ }
287
+
288
+ return reasons;
289
+ }
290
+
291
+ function buildPlanBodyRequiredMessage(reasons = []) {
292
+ const uniqueReasons = [...new Set(reasons)];
293
+ return [
294
+ "[VALIDATION FAILED] ticket create requires a filled Phase 1 plan body with actual data.",
295
+ `Missing or incomplete: ${uniqueReasons.join(", ")}`,
296
+ "Use the one-shot flow: collect real observations first, pass a filled body with `--plan-body-file -`, then run `ticket create` once.",
297
+ "If a scratch plan-body file is unavoidable, keep it outside the workspace, delete it after create, and never present it as a ticket artifact.",
298
+ "Do not rely on template defaults or auto-generated filler text for Phase 1 ticket content."
299
+ ].join("\n");
300
+ }
301
+
302
+ function getAnalysisDesignIncompleteReasons(meta, content) {
303
+ const reasons = [];
304
+ for (const requirement of ANALYSIS_DESIGN_SECTION_REQUIREMENTS) {
305
+ if (!hasSubstantiveSectionContent(extractMarkdownSection(content, requirement.section), requirement.scaffolds)) {
306
+ reasons.push(requirement.reason);
307
+ }
308
+ }
309
+
310
+ return reasons;
311
+ }
312
+
313
+ function hasSubstantiveFollowUpDecision(content) {
314
+ return hasSubstantiveSectionContent(extractMarkdownSection(content, "Follow-up Decision"), []);
315
+ }
316
+
317
+ function followUpDecisionMeansNoFollowUp(content) {
318
+ const text = extractMarkdownSection(content, "Follow-up Decision");
319
+ if (!hasSubstantiveSectionContent(text, [])) return false;
320
+ return FOLLOW_UP_DECISION_NO_FOLLOW_UP_PATTERNS.some(pattern => pattern.test(text));
321
+ }
322
+
323
+ function getCloseLifecycleReasons(meta, content) {
324
+ const reasons = [];
325
+ const problemAnalysis = extractMarkdownSection(content, "Problem Analysis");
326
+ if (!hasSubstantiveSectionContent(problemAnalysis, ANALYSIS_DESIGN_SECTION_REQUIREMENTS[0].scaffolds)) {
327
+ reasons.push("problem_analysis_missing");
328
+ }
329
+
330
+ if (!hasSubstantiveSectionContent(extractMarkdownSection(content, "Improvement Direction"), ANALYSIS_DESIGN_SECTION_REQUIREMENTS[1].scaffolds)
331
+ && !hasSubstantiveFollowUpDecision(content)) {
332
+ reasons.push("follow_up_decision_missing");
333
+ }
334
+
335
+ return reasons;
336
+ }
337
+
338
+ function validateClaimAgainstTicketContent(meta, content, claim) {
339
+ const reasons = [];
340
+ const claimTerms = tokenizeClaimText(claim);
341
+ if (claimTerms.length === 0) {
342
+ reasons.push("claim_terms_missing");
343
+ return reasons;
344
+ }
345
+
346
+ const coverageText = collectClaimTargetSections(content);
347
+ const coverage = buildClaimCoverageSummary(claimTerms, coverageText);
348
+ const matchingRate = coverage.matched.length / Math.max(coverage.total, 1);
349
+ if (matchingRate < 0.33) {
350
+ reasons.push(`claim_coverage_missing:${coverage.matched.length}/${coverage.total}`);
351
+ }
352
+
353
+ if (!meta.summary || hasPlaceholderTokens(meta.summary)) {
354
+ reasons.push("claim_ticket_summary_missing");
355
+ }
356
+
357
+ const phase1Missing = getAnalysisDesignIncompleteReasons(meta, content);
358
+ if (phase1Missing.length > 0) {
359
+ reasons.push("claim_ticket_incomplete_record");
360
+ }
361
+
362
+ return reasons;
363
+ }
364
+
365
+ function getClaimEvidenceResult(target, meta, content, claim) {
366
+ const reasons = validateClaimAgainstTicketContent(meta, content, claim);
367
+ const claimTerms = tokenizeClaimText(claim);
368
+ const coverage = buildClaimCoverageSummary(claimTerms, collectClaimTargetSections(content));
369
+ return {
370
+ ok: reasons.length === 0,
371
+ reasons,
372
+ ticket: target.topic,
373
+ path: target.path,
374
+ claim,
375
+ claimTerms: coverage.total,
376
+ coveredTerms: coverage.matched.length,
377
+ matchedTerms: coverage.matched,
378
+ missRate: Number((coverage.missRate * 100).toFixed(1)),
379
+ sections: {
380
+ problemAnalysis: extractMarkdownSection(content, "Problem Analysis").trim(),
381
+ sourceObservations: extractMarkdownSection(content, "Source Observations").trim(),
382
+ causeHypotheses: extractMarkdownSection(content, "Cause Hypotheses").trim(),
383
+ improvementDirection: extractMarkdownSection(content, "Improvement Direction").trim()
384
+ }
385
+ };
386
+ }
387
+
388
+ const IMPLEMENTATION_CLAIM_PATTERNS = [
389
+ /\b(?:fix|fixed|implement|implemented|apply|applied|change(?:d)?|patch(?:ed)?|resolved?)\b/i,
390
+ /(수정|구현|적용|변경|패치|해결)(?:했|됨|완료|함)?/i
391
+ ];
392
+
393
+ function claimImpliesCodeChange(text) {
394
+ const src = String(text || "").trim();
395
+ if (!src) return false;
396
+ return IMPLEMENTATION_CLAIM_PATTERNS.some(pattern => pattern.test(src));
397
+ }
398
+
399
+ function isTicketOwnedPath(relPath) {
400
+ const normalized = toPosixPath(String(relPath || ""));
401
+ return normalized.startsWith(`${AGENT_ROOT_DIR}/tickets/`) || normalized.startsWith(`${AGENT_ROOT_DIR}/docs/`);
402
+ }
403
+
404
+ function collectChangedSourceFiles(cwd, changedFilesOverride = null) {
405
+ const changed = Array.isArray(changedFilesOverride) ? changedFilesOverride : collectChangedFiles(cwd);
406
+ return changed.filter(relPath => !isTicketOwnedPath(relPath));
407
+ }
408
+
409
+ function extractLikelyAffectedFiles(text) {
410
+ 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) || [];
411
+ return [...new Set(matches.map(match => toPosixPath(match.trim()).replace(/^\.\//, "")))];
412
+ }
413
+
414
+ export function getImplementationClaimGuardResult(cwd, { claim = "", content = "", changedFiles = null } = {}) {
415
+ const changedSourceFiles = collectChangedSourceFiles(cwd, changedFiles);
416
+ const effectiveClaim = String(claim || "").trim();
417
+ const verificationOutcome = extractMarkdownSection(content, "Verification Outcome");
418
+ const candidateText = [effectiveClaim, verificationOutcome].filter(Boolean).join("\n");
419
+
420
+ if (!claimImpliesCodeChange(candidateText)) {
421
+ return { ok: true, changedFiles: changedSourceFiles, affectedFiles: [] };
422
+ }
423
+
424
+ const affectedFiles = extractLikelyAffectedFiles(candidateText);
425
+ const normalizedChanged = changedSourceFiles.map(file => toPosixPath(String(file || "")).replace(/^\.\//, ""));
426
+ const overlap = affectedFiles.filter(file => normalizedChanged.includes(file));
427
+ const reasons = [];
428
+
429
+ if (normalizedChanged.length === 0) {
430
+ reasons.push("implementation_changed_files_missing");
431
+ }
432
+ if (affectedFiles.length > 0 && overlap.length === 0) {
433
+ reasons.push("implementation_affected_files_not_changed");
434
+ }
435
+
436
+ return {
437
+ ok: reasons.length === 0,
438
+ reasons,
439
+ changedFiles: changedSourceFiles,
440
+ affectedFiles,
441
+ overlap
442
+ };
443
+ }
444
+
445
+ function isCompactTicketOutput(opts = {}) {
446
+ return Boolean(opts.compact || opts.nonInteractive);
447
+ }
448
+
449
+ function printUsageReminder(cwd, opts = {}) {
450
+ if (isCompactTicketOutput(opts) || opts.pathOnly) return;
451
+ const reminder = getUsageReminderLine(cwd);
452
+ if (reminder) {
453
+ console.log(reminder);
454
+ }
455
+ }
456
+
457
+ function printCreateApprovalGate(ticketId, opts = {}) {
458
+ if (isCompactTicketOutput(opts)) {
459
+ console.log("Approval pending: explicit user approval is required before work.");
460
+ console.log(`Guard topic: ${ticketId}`);
461
+ return;
462
+ }
463
+ console.log("Approval pending: share the ticket-start line in chat, review the durable ticket body, and stop here until the user explicitly approves.");
464
+ console.log(`After approval: deuk-agent-flow ticket guard --topic ${ticketId} --ticket-started --ticket-reviewed --approval approved`);
465
+ }
466
+
467
+ function formatTicketStartLine(ticketId, absPath) {
468
+ return `Ticket start: [${ticketId}](${absPath})`;
469
+ }
470
+
471
+ function printTicketStartLine(ticketId, absPath) {
472
+ console.log(formatTicketStartLine(ticketId, absPath));
473
+ }
474
+
475
+ function printTicketSelectionLine(ticketId, absPath, opts = {}) {
476
+ if (opts.pathOnly) {
477
+ console.log(absPath);
478
+ return;
479
+ }
480
+ if (isCompactTicketOutput(opts)) {
481
+ printTicketStartLine(ticketId, absPath);
482
+ return;
483
+ }
484
+ printTicketStartLine(ticketId, absPath);
485
+ }
486
+
487
+ function getHandoffSummary(out) {
488
+ const next = out.nextTicket ? `${out.nextTicket.id}:${out.nextTicket.status}` : "none";
489
+ const blockers = out.reasons?.length ? out.reasons.join(",") : "none";
490
+ const telemetry = out.telemetrySummary || "telemetry none";
491
+ return `${out.current.id} | phase=${out.current.phase} | status=${out.current.status} | next=${next} | blockers=${blockers} | ${telemetry}`;
492
+ }
493
+
494
+ function buildTicketContentSection(ticketContent, docsLanguage) {
495
+ const trimmed = String(ticketContent || "").trim();
496
+ if (!trimmed) return "";
497
+ const heading = docsLanguage === "ko" ? "## 맥락" : "## Context";
498
+ return `${heading}\n\n${trimmed}\n`;
499
+ }
500
+
501
+ function readCliTextInput(cwd, inputPath, label) {
502
+ const value = String(inputPath || "").trim();
503
+ if (!value) return "";
504
+ if (value === "-") return readFileSync(0, "utf8");
505
+ const absPath = resolve(cwd, value);
506
+ if (!existsSync(absPath)) throw new Error(`${label}: file not found ${value}`);
507
+ return readFileSync(absPath, "utf8");
508
+ }
509
+
510
+ function hydrateCreateTextInputs(opts) {
511
+ const next = { ...opts };
512
+ if (next.planBodyFile) {
513
+ next.planBody = readCliTextInput(next.cwd, next.planBodyFile, "ticket create --plan-body-file");
514
+ }
515
+ if (next.contentFile) {
516
+ next.content = readCliTextInput(next.cwd, next.contentFile, "ticket create --content-file");
517
+ }
518
+ return next;
519
+ }
520
+
521
+ function injectTicketContent(baseContent, ticketContent, docsLanguage) {
522
+ const section = buildTicketContentSection(ticketContent, docsLanguage);
523
+ if (!section) return baseContent;
524
+ if (/^## Tasks\b/m.test(baseContent)) {
525
+ return baseContent.replace(/^## Tasks\b/m, `${section}\n## Tasks`);
526
+ }
527
+ return `${String(baseContent || "").trimEnd()}\n\n${section}`;
528
+ }
529
+
530
+ function lintTicketLifecycleMarkdown(cwd, targets, context) {
531
+ const uniqueTargets = Array.from(new Set((targets || []).filter(Boolean)));
532
+ if (uniqueTargets.length === 0) return { errors: [], targets: [] };
533
+
534
+ const result = lintMarkdownPaths(uniqueTargets, cwd);
535
+ if (result.errors.length > 0) {
536
+ const details = result.errors.map(err => `- ${err}`).join("\n");
537
+ throw new Error(`[VALIDATION FAILED] ${context}: markdown lint failed\n${details}`);
538
+ }
539
+ return result;
540
+ }
541
+
542
+ function runTicketLifecycleQualityGate(cwd, { ticketAbsPath, extraTargets = [], context }) {
543
+ const lintTargets = collectTicketLifecycleMarkdownTargets(cwd, ticketAbsPath, extraTargets);
544
+ lintTicketLifecycleMarkdown(cwd, lintTargets, context);
545
+
546
+ const changedFiles = collectChangedFiles(cwd);
547
+ if (!existsSync(join(cwd, "core-rules", "AGENTS.md"))) return;
548
+ if (!shouldRunLifecycleRulesAudit(changedFiles)) return;
549
+
550
+ const result = auditRules(cwd);
551
+ if (result.ok) return;
552
+
553
+ const details = result.violations.map(violation => `- ${violation.code}: ${violation.message}`).join("\n");
554
+ throw new Error(`[VALIDATION FAILED] ${context}: rules audit failed\n${details}`);
555
+ }
556
+
557
+ function looksLikeTicketMarkdownPath(value) {
558
+ const raw = String(value || "");
559
+ return /\.md$/i.test(raw) && /[/\\]/.test(raw);
560
+ }
561
+
562
+ function findTicketRepoRootFromPath(absPath) {
563
+ let dir = dirname(absPath);
564
+ while (true) {
565
+ if (basename(dir) === TICKET_SUBDIR && basename(dirname(dir)) === AGENT_ROOT_DIR) {
566
+ return dirname(dirname(dir));
567
+ }
568
+ const parent = dirname(dir);
569
+ if (parent === dir) return null;
570
+ dir = parent;
571
+ }
572
+ }
573
+
574
+ function applyTicketPathContext(opts = {}) {
575
+ const rawTicketPath = opts.ticketPath || (looksLikeTicketMarkdownPath(opts.topic) ? opts.topic : "");
576
+ if (!rawTicketPath) return opts;
577
+
578
+ const absPath = resolve(opts.cwd, rawTicketPath);
579
+ if (!existsSync(absPath)) {
580
+ throw new Error(`ticket path not found: ${rawTicketPath}`);
581
+ }
582
+
583
+ const ticketRepoRoot = findTicketRepoRootFromPath(absPath);
584
+ if (!ticketRepoRoot) {
585
+ throw new Error(`ticket path is not inside ${AGENT_ROOT_DIR}/${TICKET_SUBDIR}: ${rawTicketPath}`);
586
+ }
587
+
588
+ const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
589
+ const ticketTopic = meta.topic || meta.id || basename(absPath).replace(/\.md$/i, "");
590
+ if (!ticketTopic) {
591
+ throw new Error(`ticket path has no id/topic frontmatter: ${rawTicketPath}`);
592
+ }
593
+
594
+ opts.cwd = ticketRepoRoot;
595
+ opts.topic = ticketTopic;
596
+ opts.ticketPath = absPath;
597
+ return opts;
598
+ }
599
+
600
+ function ticketIndexPathForRoot(root) {
601
+ return join(root, AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_INDEX_FILENAME);
602
+ }
603
+
604
+ function collectNearbyTicketRoots(cwd) {
605
+ const roots = new Set();
606
+ let dir = resolve(cwd);
607
+
608
+ while (true) {
609
+ if (existsSync(ticketIndexPathForRoot(dir))) roots.add(dir);
610
+
611
+ try {
612
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
613
+ if (!item.isDirectory() || item.name.startsWith(".")) continue;
614
+ const childRoot = join(dir, item.name);
615
+ if (existsSync(ticketIndexPathForRoot(childRoot))) roots.add(childRoot);
616
+ }
617
+ } catch {
618
+ // Some ancestors may not be readable; skip them and keep climbing.
619
+ }
620
+
621
+ const parent = dirname(dir);
622
+ if (parent === dir || basename(dir) === "home") break;
623
+ dir = parent;
624
+ }
625
+
626
+ return [...roots];
627
+ }
628
+
629
+ function matchingTicketEntriesForTopic(indexJson, topic, opts = {}) {
630
+ const key = String(topic || "").toLowerCase();
631
+ if (!key) return [];
632
+ const rows = filterTicketEntries(indexJson.entries, opts);
633
+ const exact = rows.filter(entry =>
634
+ String(entry.topic || "").toLowerCase() === key ||
635
+ String(entry.id || "").toLowerCase() === key
636
+ );
637
+ if (exact.length > 0) return exact;
638
+ return rows.filter(entry =>
639
+ String(entry.topic || "").toLowerCase().includes(key) ||
640
+ String(entry.id || "").toLowerCase().includes(key)
641
+ );
642
+ }
643
+
644
+ function applyTicketTopicContext(opts = {}) {
645
+ if (!opts.topic || opts.latest || looksLikeTicketMarkdownPath(opts.topic)) return opts;
646
+
647
+ const currentIndexPath = ticketIndexPathForRoot(opts.cwd);
648
+ if (existsSync(currentIndexPath)) {
649
+ const currentIndex = readTicketIndexJson(opts.cwd);
650
+ if (pickTicketEntry(opts, currentIndex)) return opts;
651
+ }
652
+
653
+ const hasProjectScope = Boolean(opts.project || opts.submodule);
654
+ if (hasProjectScope) return opts;
655
+
656
+ const matches = [];
657
+ for (const root of collectNearbyTicketRoots(opts.cwd)) {
658
+ if (resolve(root) === resolve(opts.cwd)) continue;
659
+ let indexJson;
660
+ try {
661
+ indexJson = JSON.parse(readFileSync(ticketIndexPathForRoot(root), "utf8"));
662
+ } catch {
663
+ continue;
664
+ }
665
+ for (const entry of matchingTicketEntriesForTopic(indexJson, opts.topic, opts)) {
666
+ matches.push({ root, entry });
667
+ }
668
+ }
669
+
670
+ if (matches.length === 0) return opts;
671
+ if (matches.length > 1) {
672
+ const details = matches
673
+ .slice(0, 10)
674
+ .map(match => `- ${match.entry.id || match.entry.topic} @ ${match.root}`)
675
+ .join("\n");
676
+ throw new Error(`Ambiguous ticket topic "${opts.topic}" found in multiple repositories:\n${details}`);
677
+ }
678
+
679
+ opts.cwd = matches[0].root;
680
+ opts.topic = matches[0].entry.topic || matches[0].entry.id;
681
+ return opts;
682
+ }
683
+
684
+ function applyTicketContext(opts = {}) {
685
+ applyTicketPathContext(opts);
686
+ applyTicketTopicContext(opts);
687
+ return opts;
688
+ }
689
+
690
+ function applyTicketRootContext(opts = {}, options = {}) {
691
+ const root = resolveConsumerTicketRoot(opts.cwd, options);
692
+ if (root) opts.cwd = root;
693
+ return opts;
694
+ }
695
+
696
+ function normalizeMarkdownLintTargets(cwd, targets = []) {
697
+ return Array.from(new Set(
698
+ (targets || [])
699
+ .filter(Boolean)
700
+ .map(target => resolve(cwd, target))
701
+ ));
702
+ }
703
+
704
+ function collectTicketLifecycleMarkdownTargets(cwd, ticketAbsPath, extraTargets = []) {
705
+ return normalizeMarkdownLintTargets(cwd, [
706
+ ticketAbsPath,
707
+ ...(extraTargets || [])
708
+ ]);
709
+ }
710
+
711
+ function shouldRunLifecycleRulesAudit(changedFiles = []) {
712
+ return (changedFiles || []).some(relPath => {
713
+ const normalized = String(relPath || "").replace(/\\/g, "/");
714
+ if (!normalized) return false;
715
+ return normalized === "core-rules/AGENTS.md"
716
+ || normalized === "PROJECT_RULE.md"
717
+ || normalized === "AGENTS.md"
718
+ || normalized === ".codex/AGENTS.md"
719
+ || normalized.startsWith(".claude/rules/")
720
+ || normalized.startsWith(".aiassistant/rules/")
721
+ || normalized.startsWith(".windsurf/rules/")
722
+ || normalized.startsWith(".cursor/rules/")
723
+ || normalized.startsWith("templates/rules.d/")
724
+ || normalized === "templates/PROJECT_RULE.md"
725
+ || normalized === "scripts/lint-rules.mjs";
726
+ });
727
+ }
728
+
729
+ function restoreTicketIndexSnapshot(cwd, snapshot, opts = {}) {
730
+ if (opts.dryRun) return;
731
+ writeTicketIndexJson(cwd, snapshot, opts);
732
+ }
733
+
734
+ function rollbackTicketLifecycleArtifacts(cwd, previousIndex, previousBody, absPath, opts = {}) {
735
+ if (opts.dryRun) return;
736
+ if (previousBody !== undefined && absPath) {
737
+ writeFileSync(absPath, previousBody, "utf8");
738
+ }
739
+ if (previousIndex) {
740
+ restoreTicketIndexSnapshot(cwd, previousIndex, opts);
741
+ }
742
+ }
743
+
744
+ function getPhase1IncompleteReasonsFromBody(body) {
745
+ parseFrontMatter(body);
746
+ return [];
747
+ }
748
+
749
+ function getPhase1IncompleteReasons(cwd, absPath) {
750
+ if (!existsSync(absPath)) return ["ticket_file_missing"];
751
+ return getPhase1IncompleteReasonsFromBody(readFileSync(absPath, "utf8"));
752
+ }
753
+
754
+ function isExecutionTicketStatus(status) {
755
+ return OPEN_TICKET_STATUSES.has(String(status || "open").toLowerCase());
756
+ }
757
+
758
+ function hasExplicitExecutionApproval(opts = {}) {
759
+ if (!Object.prototype.hasOwnProperty.call(opts, "workflowMode")
760
+ && !Object.prototype.hasOwnProperty.call(opts, "workflow")
761
+ && !Object.prototype.hasOwnProperty.call(opts, "approval")
762
+ && !Object.prototype.hasOwnProperty.call(opts, "approvalState")) {
763
+ return false;
764
+ }
765
+ return normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approval ?? opts.approvalState) === WORKFLOW_MODE_EXECUTE;
766
+ }
767
+
768
+ function getTicketLifecycleProvenanceReasons(entry, meta = {}) {
769
+ const reasons = [];
770
+ if (!entry || String(entry.status || "").toLowerCase() === "archived") return reasons;
771
+ if (!isExecutionTicketStatus(entry.status || meta.status || "open")) return reasons;
772
+
773
+ const lifecycleSource = String(meta.lifecycleSource || meta.ticketLifecycleSource || "").trim();
774
+ if (lifecycleSource !== "ticket-create") {
775
+ reasons.push("manual_ticket_lifecycle_provenance_missing");
776
+ }
777
+ return reasons;
778
+ }
779
+
780
+ function assertTicketLifecycleProvenance(entry, meta = {}) {
781
+ const reasons = getTicketLifecycleProvenanceReasons(entry, meta);
782
+ if (reasons.length === 0) return;
783
+ throw new Error([
784
+ `[VALIDATION FAILED] Ticket ${entry?.id || entry?.topic || "unknown"} cannot be used as an execution ticket: ${reasons.join(", ")}.`,
785
+ "This ticket file does not carry CLI creation provenance.",
786
+ "Do not create or repair tickets by writing .deuk-agent/tickets/**/*.md directly.",
787
+ "Use: npx deuk-agent-flow ticket create --topic <topic> --summary <summary> --plan-body-file - --non-interactive"
788
+ ].join("\n"));
789
+ }
790
+
791
+ function updatePreviousTicketRef(cwd, prevTicketEntry, ticketId) {
792
+ if (!prevTicketEntry) return;
793
+ const prevAbsPath = join(cwd, prevTicketEntry.path);
794
+ if (!existsSync(prevAbsPath)) return;
795
+
796
+ let prevContent = readFileSync(prevAbsPath, "utf8");
797
+ prevContent = prevContent.replace(/^---\n([\s\S]*?)\n---/, (match, fm) => {
798
+ if (!fm.includes('nextTicket:')) {
799
+ return `---\n${fm.trim()}\nnextTicket: ${ticketId}\n---`;
800
+ }
801
+ return match;
802
+ });
803
+ writeFileSync(prevAbsPath, prevContent, "utf8");
804
+ return prevTicketEntry.id;
805
+ }
806
+
807
+ function archivePartitionForEntry(entry, now = new Date()) {
808
+ const storedYearMonth = String(entry?.archiveYearMonth || "");
809
+ if (/^\d{4}-\d{2}$/.test(storedYearMonth)) {
810
+ return { yearMonth: storedYearMonth };
811
+ }
812
+
813
+ const source = String(entry?.createdAt || "");
814
+ const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
815
+ if (match) return { yearMonth: `${match[1]}-${match[2]}` };
816
+
817
+ const iso = now.toISOString();
818
+ return { yearMonth: iso.slice(0, 7) };
819
+ }
820
+
821
+ function getArchiveDestination(ticketDir, entry, fileName) {
822
+ const partition = archivePartitionForEntry(entry);
823
+ const archiveDir = join(ticketDir, "archive", entry.group || "sub", partition.yearMonth);
824
+ return {
825
+ archiveDir,
826
+ archiveYearMonth: partition.yearMonth,
827
+ newAbsPath: join(archiveDir, fileName)
828
+ };
829
+ }
830
+
831
+ function archiveStorageFromPath(ticketDir, absPath, entry) {
832
+ const parts = toPosixPath(relative(ticketDir, absPath)).split("/");
833
+ const archiveIdx = parts.indexOf("archive");
834
+ if (archiveIdx < 0) return archivePartitionForEntry(entry);
835
+ return {
836
+ archiveYearMonth: parts[archiveIdx + 2] || archivePartitionForEntry(entry).yearMonth
837
+ };
838
+ }
839
+
840
+ function findExistingArchivedTicketPath(ticketDir, entry, fileName) {
841
+ const expected = getArchiveDestination(ticketDir, entry, fileName).newAbsPath;
842
+ if (existsSync(expected)) return expected;
843
+
844
+ const archiveRoot = join(ticketDir, "archive", entry.group || "sub");
845
+ if (!existsSync(archiveRoot)) return null;
846
+
847
+ const stack = [archiveRoot];
848
+ while (stack.length > 0) {
849
+ const dir = stack.pop();
850
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
851
+ const abs = join(dir, item.name);
852
+ if (item.isDirectory()) {
853
+ stack.push(abs);
854
+ } else if (item.isFile() && item.name === fileName) {
855
+ return abs;
856
+ }
857
+ }
858
+ }
859
+ return null;
860
+ }
861
+
862
+ function isOpenTicketEntry(entry) {
863
+ return OPEN_TICKET_STATUSES.has(String(entry?.status || "open"));
864
+ }
865
+
866
+ function isAutoArchivableDoneEntry(entry) {
867
+ return AUTO_ARCHIVE_DONE_STATUSES.has(String(entry?.status || "").toLowerCase());
868
+ }
869
+
870
+ function latestTicketByStatus(entries, statuses) {
871
+ const statusSet = new Set(statuses);
872
+ return [...(entries || [])]
873
+ .filter(e => statusSet.has(String(e.status || "").toLowerCase()))
874
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0] || null;
875
+ }
876
+
877
+ function formatTicketChoice(entry) {
878
+ const status = String(entry.status || "open");
879
+ const createdAt = String(entry.createdAt || "-");
880
+ const title = String(entry.title || entry.topic || entry.id || "").replace(/(\n|\\n)+/g, " ").slice(0, 80);
881
+ return `${entry.id} | ${status} | ${createdAt} | ${title}`;
882
+ }
883
+
884
+ function buildUseFallbackCandidates(indexJson, opts = {}) {
885
+ const entries = filterTicketEntries(indexJson.entries, opts);
886
+ const lastClosed = latestTicketByStatus(entries, ["closed"]);
887
+ const openRows = entries
888
+ .filter(e => OPEN_TICKET_STATUSES.has(String(e.status || "open")))
889
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")));
890
+
891
+ const seen = new Set();
892
+ return [lastClosed, ...openRows]
893
+ .filter(Boolean)
894
+ .filter(entry => {
895
+ if (seen.has(entry.id)) return false;
896
+ seen.add(entry.id);
897
+ return true;
898
+ });
899
+ }
900
+
901
+ function buildUseNoMatchError(topic, candidates) {
902
+ const lines = [
903
+ `No matching ticket found for "${topic || ""}".`,
904
+ "Last closed ticket and open tickets:"
905
+ ];
906
+
907
+ if (candidates.length === 0) {
908
+ lines.push(" - none");
909
+ } else {
910
+ for (const entry of candidates.slice(0, 20)) {
911
+ lines.push(` - ${formatTicketChoice(entry)}`);
912
+ }
913
+ }
914
+
915
+ lines.push("");
916
+ lines.push("Choose one explicitly:");
917
+ lines.push(" npx deuk-agent-flow ticket use --topic <ticket-id> --non-interactive");
918
+ return lines.join("\n");
919
+ }
920
+
921
+ function oldestFirst(a, b) {
922
+ return String(a.createdAt || "").localeCompare(String(b.createdAt || ""));
923
+ }
924
+
925
+ function selectOpenLimitCandidates(indexJson) {
926
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
927
+ const overflow = openRows.length - MAX_OPEN_TICKETS;
928
+ if (overflow <= 0) return [];
929
+
930
+ const currentActiveId = indexJson.activeTicketId;
931
+ const openCandidates = openRows
932
+ .filter(e => e.status === "open" && e.id !== currentActiveId)
933
+ .sort(oldestFirst);
934
+ const activeCandidates = openRows
935
+ .filter(e => e.status === "active" && e.id !== currentActiveId)
936
+ .sort(oldestFirst);
937
+ const lastResort = openRows
938
+ .filter(e => e.id === currentActiveId)
939
+ .sort(oldestFirst);
940
+
941
+ return [...openCandidates, ...activeCandidates, ...lastResort].slice(0, overflow);
942
+ }
943
+
944
+ function buildOpenTicketLimitError(indexJson) {
945
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
946
+ if (openRows.length <= MAX_OPEN_TICKETS) return null;
947
+
948
+ const candidates = selectOpenLimitCandidates(indexJson);
949
+ const lines = [
950
+ `[OPEN TICKET LIMIT] Open tickets: ${openRows.length}/${MAX_OPEN_TICKETS}.`,
951
+ "Ticket creation was cancelled so open tickets do not exceed the limit.",
952
+ "Review the active ticket list, decide what can be archived, then create the ticket again.",
953
+ "",
954
+ "Commands:",
955
+ " npx deuk-agent-flow ticket list --active --non-interactive",
956
+ " npx deuk-agent-flow ticket archive --topic <ticket-id> --non-interactive",
957
+ "",
958
+ "Oldest archive candidates:"
959
+ ];
960
+
961
+ for (const entry of candidates.slice(0, 10)) {
962
+ const title = String(entry.title || entry.topic || "").replace(/(\n|\\n)+/g, " ").slice(0, 80);
963
+ lines.push(` - ${entry.id} | ${entry.status || "open"} | ${entry.createdAt || "-"} | ${title}`);
964
+ }
965
+
966
+ return lines.join("\n");
967
+ }
968
+
969
+ function resolveArchiveReport(cwd, fileName, report) {
970
+ if (report) return resolve(cwd, report);
971
+
972
+ const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
973
+ const potentialReport = fileName.replace(/\.md$/i, "-report.md");
974
+ const potentialPath = join(reportDir, potentialReport);
975
+ return existsSync(potentialPath) ? potentialPath : null;
976
+ }
977
+
978
+ function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {}, report }) {
979
+ const absPath = join(cwd, found.path);
980
+ const fileName = found.path.split(/[/\\]/).pop();
981
+ if (!existsSync(absPath)) {
982
+ const archivedAbsPath = findExistingArchivedTicketPath(ticketDir, found, fileName);
983
+ if (archivedAbsPath) {
984
+ const storage = archiveStorageFromPath(ticketDir, archivedAbsPath, found);
985
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
986
+ if (entryIdx >= 0) {
987
+ indexJson.entries[entryIdx].fileName = fileName;
988
+ indexJson.entries[entryIdx].status = "archived";
989
+ indexJson.entries[entryIdx].archiveYearMonth = storage.archiveYearMonth;
990
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
991
+ }
992
+ const archivedRelativePath = toRepoRelativePath(cwd, archivedAbsPath);
993
+ if (!isCompactTicketOutput(opts)) {
994
+ console.warn("ticket archive: repaired already archived ticket " + archivedRelativePath);
995
+ }
996
+ return { id: found.id, path: archivedRelativePath, repaired: true };
997
+ }
998
+ if (String(found.status || "").toLowerCase() === "closed" && found.archiveYearMonth) {
999
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1000
+ if (entryIdx >= 0) {
1001
+ indexJson.entries[entryIdx].fileName = fileName;
1002
+ indexJson.entries[entryIdx].status = "archived";
1003
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1004
+ }
1005
+ const archivedRelativePath = computeTicketPath({
1006
+ ...found,
1007
+ fileName,
1008
+ status: "archived"
1009
+ });
1010
+ if (!isCompactTicketOutput(opts)) {
1011
+ console.warn("ticket archive: normalized stale closed ticket metadata " + archivedRelativePath);
1012
+ }
1013
+ return { id: found.id, path: archivedRelativePath, normalized: true };
1014
+ }
1015
+ throw new Error("ticket archive: file not found " + found.path);
1016
+ }
1017
+
1018
+ const originalBody = readFileSync(absPath, "utf8");
1019
+ const { meta: archiveMeta } = parseFrontMatter(originalBody);
1020
+ const { archiveDir, archiveYearMonth, newAbsPath } = getArchiveDestination(ticketDir, found, fileName);
1021
+ if (!opts.dryRun) mkdirSync(archiveDir, { recursive: true });
1022
+
1023
+ const bodyLines = originalBody.trimEnd().split(/\r?\n/);
1024
+ const reportSrc = resolveArchiveReport(cwd, fileName, report);
1025
+ let reportDest = null;
1026
+
1027
+ if (reportSrc) {
1028
+ if (!existsSync(reportSrc)) {
1029
+ throw new Error("ticket archive: report file not found " + report);
1030
+ }
1031
+ const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
1032
+ if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
1033
+
1034
+ const reportBaseName = fileName.replace(/\.md$/i, "-report.md");
1035
+ reportDest = join(reportDir, reportBaseName);
1036
+ if (!opts.dryRun) copyFileSync(reportSrc, reportDest);
1037
+ if (!isCompactTicketOutput(opts)) {
1038
+ console.log("ticket archive: copied report to " + toFileUri(reportDest));
1039
+ }
1040
+
1041
+ bodyLines.push("");
1042
+ bodyLines.push("## 📄 Attached Report");
1043
+ const relativeLink = toPosixPath(relative(dirname(newAbsPath), reportDest));
1044
+ bodyLines.push(`- [View Report](${relativeLink})`);
1045
+ }
1046
+
1047
+ if (opts.dryRun) {
1048
+ if (!isCompactTicketOutput(opts)) {
1049
+ console.log("ticket archive: would move " + toRepoRelativePath(cwd, absPath) + " to " + toRepoRelativePath(cwd, newAbsPath));
1050
+ }
1051
+ return { dryRun: true };
1052
+ }
1053
+
1054
+ writeFileSync(newAbsPath, bodyLines.join("\n") + "\n", "utf8");
1055
+ rmSync(absPath);
1056
+ try {
1057
+ runTicketLifecycleQualityGate(cwd, {
1058
+ ticketAbsPath: newAbsPath,
1059
+ extraTargets: reportDest ? [reportDest] : [],
1060
+ context: `ticket archive ${found.id}`
1061
+ });
1062
+ } catch (err) {
1063
+ rmSync(newAbsPath, { force: true });
1064
+ writeFileSync(absPath, originalBody, "utf8");
1065
+ if (reportDest) rmSync(reportDest, { force: true });
1066
+ throw err;
1067
+ }
1068
+
1069
+ distillKnowledge(absPath, found.id, cwd, originalBody);
1070
+ if (!isCompactTicketOutput(opts)) {
1071
+ console.log("ticket archive: moved ticket to " + toFileUri(newAbsPath));
1072
+ }
1073
+
1074
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1075
+ if (entryIdx >= 0) {
1076
+ indexJson.entries[entryIdx].fileName = fileName;
1077
+ indexJson.entries[entryIdx].status = "archived";
1078
+ indexJson.entries[entryIdx].archiveYearMonth = archiveYearMonth;
1079
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1080
+ }
1081
+
1082
+ const archivedRelativePath = toRepoRelativePath(cwd, newAbsPath);
1083
+ if (!isCompactTicketOutput(opts)) {
1084
+ console.log("ticket archive: final ticket path " + archivedRelativePath);
1085
+ }
1086
+ return { id: found.id, path: archivedRelativePath };
1087
+ }
1088
+
1089
+ function autoArchiveDoneTickets(cwd, indexJson, opts = {}) {
1090
+ const ticketDir = detectConsumerTicketDir(cwd);
1091
+ if (!ticketDir) return [];
1092
+
1093
+ const candidates = (indexJson.entries || [])
1094
+ .filter(isAutoArchivableDoneEntry)
1095
+ .sort(oldestFirst);
1096
+ const archived = [];
1097
+
1098
+ for (const candidate of candidates) {
1099
+ const result = archiveTicketEntry({ cwd, ticketDir, indexJson, found: candidate, opts, report: null });
1100
+ if (result?.id) {
1101
+ archived.push(result);
1102
+ if (!isCompactTicketOutput(opts)) {
1103
+ console.warn(`[AUTO-ARCHIVE] ${candidate.id} (${candidate.status}) archived before open-ticket limit check.`);
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ if (archived.length > 0) {
1109
+ writeTicketIndexJson(cwd, indexJson, opts);
1110
+ }
1111
+
1112
+ return archived;
1113
+ }
1114
+
1115
+ function canAutoArchiveOpenLimit(indexJson) {
1116
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
1117
+ if (openRows.length <= MAX_OPEN_TICKETS) {
1118
+ return { needed: 0, candidates: [], ok: true };
1119
+ }
1120
+
1121
+ const candidates = selectOpenLimitCandidates(indexJson);
1122
+ const needed = openRows.length - MAX_OPEN_TICKETS;
1123
+ return {
1124
+ needed,
1125
+ candidates,
1126
+ ok: candidates.length >= needed
1127
+ };
1128
+ }
1129
+
1130
+ function autoArchiveOpenLimitTickets(cwd, indexJson, opts = {}) {
1131
+ const ticketDir = detectConsumerTicketDir(cwd);
1132
+ if (!ticketDir) return [];
1133
+
1134
+ const { needed, candidates, ok } = canAutoArchiveOpenLimit(indexJson);
1135
+ if (needed <= 0 || !ok) return [];
1136
+
1137
+ const archived = [];
1138
+ console.warn("[AUTO-CLEANUP] Open-ticket limit reached. 자동으로 티켓 정리를 진행하겠습니다.");
1139
+ for (const candidate of candidates.slice(0, needed)) {
1140
+ const result = archiveTicketEntry({ cwd, ticketDir, indexJson, found: candidate, opts, report: null });
1141
+ if (result?.id) {
1142
+ archived.push(result);
1143
+ if (!isCompactTicketOutput(opts)) {
1144
+ console.warn(`[AUTO-CLEANUP] ${candidate.id} archived to stay within the open-ticket limit.`);
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ if (archived.length > 0) {
1150
+ writeTicketIndexJson(cwd, indexJson, opts);
1151
+ }
1152
+
1153
+ return archived;
1154
+ }
1155
+
1156
+ function rollbackCreatedTicket(cwd, abs, rollbackIndexJson, opts = {}) {
1157
+ if (opts.dryRun) return;
1158
+ rmSync(abs, { force: true });
1159
+ writeTicketIndexJson(cwd, rollbackIndexJson, opts);
1160
+ }
1161
+
1162
+ function buildCreateRollbackIndex(currentIndexJson, ticketId, previousIndexJson) {
1163
+ return {
1164
+ ...currentIndexJson,
1165
+ activeTicketId: previousIndexJson.activeTicketId || "",
1166
+ entries: (currentIndexJson.entries || []).filter(entry => entry.id !== ticketId)
1167
+ };
1168
+ }
1169
+
1170
+ export async function runTicketCreate(opts) {
1171
+ applyTicketRootContext(opts, { createIfMissing: true });
1172
+ opts = hydrateCreateTextInputs(opts);
1173
+ if (!opts.topic && !opts.ref) throw new Error("ticket create requires --topic or --ref");
1174
+ const inferred = opts.ref ? inferRefTitleAndTopic(opts) : null;
1175
+ const title = opts.topic || inferred?.title || "ticket";
1176
+ const topic = requireNonEmptySlug(opts.topic || inferred?.topic || title, "ticket topic");
1177
+ const group = toSlug(opts.group || "sub");
1178
+
1179
+ await ensurePhase0Validation(opts);
1180
+
1181
+ let path, source;
1182
+ if (opts.ref) {
1183
+ path = resolveReferencedTicketPath(opts);
1184
+ source = "ticket-reference";
1185
+ } else {
1186
+ // Find nearest or create in CWD if missing
1187
+ const ticketDir = detectConsumerTicketDir(opts.cwd, { createIfMissing: true });
1188
+
1189
+ let parsedPlan = null;
1190
+ let finalTitle = title;
1191
+ let finalTopic = topic;
1192
+
1193
+ if (typeof opts.planBody === "string" && opts.planBody.trim()) {
1194
+ parsedPlan = parsePlan("inline-plan-body.md", opts.planBody);
1195
+
1196
+ finalTitle = opts.topic || parsedPlan.title || title;
1197
+ finalTopic = requireNonEmptySlug(finalTitle, "ticket topic");
1198
+ }
1199
+
1200
+ const indexJson = readTicketIndexJson(opts.cwd);
1201
+
1202
+ // Smart close: check previous active ticket's completion state before deciding
1203
+ const activeId = indexJson.activeTicketId;
1204
+ if (activeId) {
1205
+ const activeEntry = indexJson.entries.find(e => e.id === activeId && (e.status === "open" || e.status === "active"));
1206
+ if (activeEntry) {
1207
+ const absPath = join(opts.cwd, activeEntry.path);
1208
+ let shouldClose = false;
1209
+ let reason = "";
1210
+
1211
+ if (existsSync(absPath)) {
1212
+ try {
1213
+ const body = readFileSync(absPath, "utf8");
1214
+ const { meta, content } = parseFrontMatter(body);
1215
+
1216
+ // Count checklist items
1217
+ const checked = (content.match(/- \[x\]/gi) || []).length;
1218
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
1219
+ const total = checked + unchecked;
1220
+ const allDone = total > 0 && unchecked === 0;
1221
+ const phase = meta.phase || 1;
1222
+
1223
+ if (phase >= 3 && allDone) {
1224
+ shouldClose = true;
1225
+ reason = `phase=${phase}, tasks=${checked}/${total} done`;
1226
+ } else if (allDone && total > 0) {
1227
+ shouldClose = true;
1228
+ reason = `all tasks done (${checked}/${total}), phase=${phase}`;
1229
+ } else {
1230
+ reason = `phase=${phase}, tasks=${checked}/${total} done`;
1231
+ }
1232
+ } catch (err) {
1233
+ reason = "could not read ticket file";
1234
+ }
1235
+ }
1236
+
1237
+ if (shouldClose) {
1238
+ if (opts.dryRun) {
1239
+ if (!isCompactTicketOutput(opts)) {
1240
+ console.log(`[DRY-RUN] Would auto-close ${activeId} (${reason}).`);
1241
+ }
1242
+ } else {
1243
+ activeEntry.status = "closed";
1244
+ activeEntry.updatedAt = new Date().toISOString();
1245
+ // Sync to frontmatter
1246
+ if (existsSync(absPath)) {
1247
+ try {
1248
+ const body = readFileSync(absPath, "utf8");
1249
+ const parsed = parseFrontMatter(body);
1250
+ parsed.meta.status = "closed";
1251
+ parsed.meta.phase = 4;
1252
+ writeFileSync(absPath, stringifyFrontMatter(parsed.meta, parsed.content), "utf8");
1253
+ } catch (err) { /* skip */ }
1254
+ }
1255
+ writeTicketIndexJson(opts.cwd, indexJson, opts);
1256
+ if (!isCompactTicketOutput(opts)) {
1257
+ console.log(`[AUTO-CLOSE] ${activeId} completed (${reason}).`);
1258
+ }
1259
+ }
1260
+ } else {
1261
+ if (!isCompactTicketOutput(opts)) {
1262
+ console.warn(`[NOTICE] Switching from ${activeId} (${reason}). Ticket stays open.`);
1263
+ }
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+
1269
+
1270
+
1271
+ const ticketId = generateTicketId(finalTopic, indexJson.entries);
1272
+ const finalFileName = `${ticketId}.md`;
1273
+
1274
+ const abs = join(ticketDir, group, finalFileName);
1275
+ if (!opts.dryRun) mkdirSync(join(ticketDir, group), { recursive: true });
1276
+ path = toRepoRelativePath(opts.cwd, abs);
1277
+
1278
+ let prevTicketEntry = null;
1279
+ if (opts.chain) {
1280
+ prevTicketEntry = pickTicketEntry({ latest: true }, indexJson);
1281
+ }
1282
+
1283
+ const summary = (opts.summary || parsedPlan?.summary || finalTitle || finalTopic || "ticket").trim();
1284
+
1285
+ const promptText = [summary, finalTitle, parsedPlan?.body].filter(Boolean).join("\n");
1286
+ const docsLanguage = resolveTicketDocsLanguage(opts.cwd, opts.docsLanguage, promptText);
1287
+
1288
+ const rawMeta = {
1289
+ id: ticketId,
1290
+ title: finalTitle,
1291
+ phase: 1,
1292
+ status: "open",
1293
+ lifecycleSource: "ticket-create",
1294
+ submodule: opts.submodule,
1295
+ project: opts.project === "global" ? undefined : opts.project,
1296
+ docsLanguage,
1297
+ evidence: opts.evidence,
1298
+ summary,
1299
+ priority: opts.priority || "P2",
1300
+ tags: opts.tags
1301
+ ? opts.tags.split(',').map(t => t.trim().replace(/^#/, '')).filter(Boolean)
1302
+ : [],
1303
+ createdAt: new Date().toISOString().replace('T', ' ').split('.')[0],
1304
+ prevTicket: prevTicketEntry ? prevTicketEntry.id : undefined,
1305
+ };
1306
+
1307
+ const meta = Object.fromEntries(Object.entries(rawMeta).filter(([k, v]) => {
1308
+ if (k === 'summary') return v !== undefined; // summary는 필수이므로 undefined만 아니면 유지
1309
+ return v !== undefined && v !== "";
1310
+ }));
1311
+ const frontmatter = YAML.stringify(meta).trim();
1312
+
1313
+ let finalContent = "";
1314
+ if (parsedPlan) {
1315
+ const planReasons = getPhase1PlanBodyReasons(parsedPlan.body);
1316
+ if (planReasons.length > 0) {
1317
+ throw new Error(buildPlanBodyRequiredMessage(planReasons));
1318
+ }
1319
+ finalContent = `---\n${frontmatter}\n---\n${parsedPlan.body}`;
1320
+ } else {
1321
+ throw new Error(buildPlanBodyRequiredMessage(["plan_body_file_required"]));
1322
+ }
1323
+ finalContent = injectTicketContent(finalContent, opts.content, docsLanguage);
1324
+
1325
+ let rollbackIndexJson = indexJson;
1326
+
1327
+ if (!opts.dryRun) writeFileSync(abs, finalContent, "utf8");
1328
+ source = "ticket-create";
1329
+
1330
+ try {
1331
+ if (!opts.dryRun) {
1332
+ runTicketLifecycleQualityGate(opts.cwd, {
1333
+ ticketAbsPath: abs,
1334
+ context: `ticket create ${ticketId}`
1335
+ });
1336
+ }
1337
+
1338
+ if (opts.dryRun) {
1339
+ const simulatedIndexJson = {
1340
+ ...indexJson,
1341
+ entries: [
1342
+ ...(indexJson.entries || []),
1343
+ {
1344
+ id: ticketId,
1345
+ title,
1346
+ topic,
1347
+ group,
1348
+ project: opts.project || "global",
1349
+ createdAt: new Date().toISOString(),
1350
+ path,
1351
+ source,
1352
+ status: "open"
1353
+ }
1354
+ ]
1355
+ };
1356
+ const autoArchiveCheck = canAutoArchiveOpenLimit(simulatedIndexJson);
1357
+ const limitError = autoArchiveCheck.ok ? null : buildOpenTicketLimitError(simulatedIndexJson);
1358
+ if (limitError) {
1359
+ throw new Error(limitError);
1360
+ }
1361
+ }
1362
+
1363
+ appendTicketEntry(opts.cwd, {
1364
+ id: ticketId,
1365
+ title, topic, group, project: opts.project || "global",
1366
+ createdAt: new Date().toISOString(), path, source
1367
+ }, opts);
1368
+
1369
+ const limitIndexJson = readTicketIndexJson(opts.cwd);
1370
+ autoArchiveOpenLimitTickets(opts.cwd, limitIndexJson, opts);
1371
+
1372
+ const limitError = buildOpenTicketLimitError(readTicketIndexJson(opts.cwd));
1373
+ if (limitError) {
1374
+ rollbackIndexJson = buildCreateRollbackIndex(readTicketIndexJson(opts.cwd), ticketId, indexJson);
1375
+ throw new Error(limitError);
1376
+ }
1377
+ } catch (err) {
1378
+ if (!opts.dryRun) {
1379
+ rollbackCreatedTicket(opts.cwd, abs, rollbackIndexJson, opts);
1380
+ }
1381
+ throw err;
1382
+ }
1383
+
1384
+ if (!opts.dryRun) {
1385
+ const linkedPrev = updatePreviousTicketRef(opts.cwd, prevTicketEntry, ticketId);
1386
+ if (linkedPrev && !isCompactTicketOutput(opts)) {
1387
+ console.log(`Linked to previous ticket: ${linkedPrev}`);
1388
+ }
1389
+ }
1390
+
1391
+ console.log(`${opts.dryRun ? "Ticket would be created" : "Ticket created"}: ${toFileUri(abs)}`);
1392
+ printTicketStartLine(ticketId, abs);
1393
+ if (!opts.dryRun) {
1394
+ printCreateApprovalGate(ticketId, opts);
1395
+ }
1396
+ printUsageReminder(opts.cwd, opts);
1397
+ if (!opts.dryRun) {
1398
+ appendTelemetryEvent(opts.cwd, {
1399
+ event: "ticket_created",
1400
+ action: "ticket-create",
1401
+ ticket: ticketId,
1402
+ file: path,
1403
+ phase: 1,
1404
+ status: "open"
1405
+ });
1406
+ }
1407
+
1408
+ // Remote Sync Hook
1409
+ const configSync = loadInitConfig(opts.cwd);
1410
+ if (!opts.dryRun && configSync && configSync.remoteSync && configSync.pipelineUrl) {
1411
+ syncToPipeline(configSync.pipelineUrl, { action: "create", ticket: meta });
1412
+ }
1413
+ }
1414
+
1415
+ syncActiveTicketId(opts.cwd, opts);
1416
+ }
1417
+
1418
+ export async function runTicketList(opts) {
1419
+ applyTicketRootContext(opts);
1420
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
1421
+ if (!ticketDir) {
1422
+ throw new Error("No ticket system found. Please run 'npx deuk-agent-flow init' first.");
1423
+ }
1424
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1425
+ syncActiveTicketId(opts.cwd);
1426
+ let rows = index.entries;
1427
+
1428
+
1429
+ if (opts.active) {
1430
+ rows = rows.filter(e => e.status === "active" || e.status === "open");
1431
+ } else if (opts.archived) {
1432
+ rows = rows.filter(e => e.status === "archived");
1433
+ } else if (!opts.all) {
1434
+ // Default: major/active list (open or active)
1435
+ rows = rows.filter(e => e.status === "open" || e.status === "active");
1436
+ }
1437
+
1438
+ if (opts.group) rows = rows.filter(e => e.group === opts.group);
1439
+ if (opts.project) rows = rows.filter(e => e.project === opts.project);
1440
+ if (opts.submodule) rows = rows.filter(e => e.submodule === opts.submodule);
1441
+
1442
+ if (opts.json) {
1443
+ console.log(JSON.stringify(rows, null, 2));
1444
+ return;
1445
+ }
1446
+
1447
+ console.log("# STATUS SUBMODULE GROUP PROJECT CREATED TITLE");
1448
+ rows.slice(0, opts.limit).forEach((e, idx) => {
1449
+ const stat = (e.status === "closed" ? "[x]" : "[ ]").padEnd(7);
1450
+ const sub = (e.submodule || "-").padEnd(11);
1451
+ const safeTitle = String(e.title || e.topic || "").replace(/(\n|\\n)+/g, " ").slice(0, 50);
1452
+ console.log(`${String(idx+1).padEnd(2)} ${stat} ${sub} ${String(e.group||"").padEnd(10)} ${String(e.project||"").padEnd(11)} ${String(e.createdAt||"").padEnd(24)} ${safeTitle}`);
1453
+ });
1454
+
1455
+ if (opts.render) {
1456
+ console.log("ticket list --render is deprecated; TICKET_LIST.md is no longer generated.");
1457
+ }
1458
+ }
1459
+
1460
+ export async function runTicketStatus(opts) {
1461
+ applyTicketRootContext(opts);
1462
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1463
+ const found = pickTicketEntry(opts, index);
1464
+ if (!found) throw new Error("ticket status: no matching ticket found");
1465
+
1466
+ const absPath = join(opts.cwd, found.path);
1467
+ const fileMissing = !existsSync(absPath);
1468
+ const body = fileMissing ? "" : readFileSync(absPath, "utf8");
1469
+ const parsed = fileMissing ? { meta: {}, content: "" } : parseFrontMatter(body);
1470
+ if (!fileMissing) assertTicketLifecycleProvenance(found, parsed.meta);
1471
+ const phase = Number(parsed.meta.phase || 1);
1472
+ const incompleteReasons = getPhase1IncompleteReasons(opts.cwd, absPath);
1473
+ const derivedStatus = incompleteReasons.length > 0 && phase === 1
1474
+ ? "phase1_incomplete"
1475
+ : (parsed.meta.status || found.status || "open");
1476
+
1477
+ const out = {
1478
+ id: found.id,
1479
+ title: found.title,
1480
+ path: found.path,
1481
+ phase,
1482
+ status: derivedStatus,
1483
+ summary: parsed.meta.summary || null,
1484
+ reasons: incompleteReasons,
1485
+ };
1486
+
1487
+ if (opts.json) {
1488
+ console.log(JSON.stringify(out, null, 2));
1489
+ return;
1490
+ }
1491
+
1492
+ if (isCompactTicketOutput(opts)) {
1493
+ const reasonText = out.reasons.length === 0 ? "ok" : out.reasons.join(", ");
1494
+ console.log(`${out.id} | phase=${out.phase} | status=${out.status} | ${reasonText}`);
1495
+ printUsageReminder(opts.cwd, opts);
1496
+ return;
1497
+ }
1498
+
1499
+ console.log(`Ticket: ${out.id}`);
1500
+ console.log(`Status: ${out.status}`);
1501
+ console.log(`Phase: ${out.phase}`);
1502
+ console.log(`Path: ${out.path}`);
1503
+ printTicketStartLine(out.id, absPath);
1504
+ if (opts.statusDetail || out.reasons.length > 0) {
1505
+ if (out.reasons.length === 0) console.log("Reasons: none");
1506
+ else console.log(`Reasons: ${out.reasons.join(", ")}`);
1507
+ }
1508
+ printUsageReminder(opts.cwd, opts);
1509
+ }
1510
+
1511
+ export async function runTicketGuard(opts) {
1512
+ applyTicketRootContext(opts);
1513
+ applyTicketContext(opts);
1514
+ if (!opts.topic && !opts.latest) {
1515
+ throw new Error("ticket guard: --topic or --latest is required before set_workflow_context.");
1516
+ }
1517
+
1518
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1519
+ const found = pickTicketEntry(opts, index);
1520
+ if (!found) {
1521
+ throw new Error("ticket guard: no matching durable ticket found; do not call set_workflow_context.");
1522
+ }
1523
+
1524
+ const absPath = join(opts.cwd, found.path);
1525
+ if (!existsSync(absPath)) {
1526
+ throw new Error(`ticket guard: durable ticket file missing for ${found.id || found.topic}; do not call set_workflow_context.`);
1527
+ }
1528
+
1529
+ const body = readFileSync(absPath, "utf8");
1530
+ const parsed = parseFrontMatter(body);
1531
+ assertTicketLifecycleProvenance(found, parsed.meta);
1532
+
1533
+ const phase = Number(parsed.meta.phase || 1);
1534
+ const reasons = getPhase1IncompleteReasons(opts.cwd, absPath);
1535
+ if (phase === 1 && reasons.length > 0) {
1536
+ throw new Error(`[VALIDATION FAILED] ticket guard rejected incomplete Phase 1 for ${found.id || found.topic}: ${reasons.join(", ")}. Do not call set_workflow_context until the durable ticket is complete.`);
1537
+ }
1538
+ if (!opts.ticketStarted) {
1539
+ throw new Error(`[ACK REQUIRED] ticket guard blocked ${found.id || found.topic}: relay this clickable ticket-start line to chat before set_workflow_context: ${formatTicketStartLine(found.id || found.topic, absPath)}. Re-run with --ticket-started only after the ticket link has been shared with the user.`);
1540
+ }
1541
+ if (!opts.ticketReviewed) {
1542
+ throw new Error(`[REVIEW REQUIRED] ticket guard blocked ${found.id || found.topic}: reopen and review the durable ticket body before set_workflow_context. Re-run with --ticket-reviewed only after checking the ticket scope, APC, plan, and verification criteria.`);
1543
+ }
1544
+ if (!hasExplicitExecutionApproval(opts)) {
1545
+ throw new Error(`[APPROVAL REQUIRED] ticket guard blocked ${found.id || found.topic}: explicit user approval is required before set_workflow_context. Re-run with --approval approved only after the user approves the ticket scope and Phase 1 plan.`);
1546
+ }
1547
+
1548
+ const out = {
1549
+ id: found.id,
1550
+ topic: found.topic,
1551
+ phase,
1552
+ status: parsed.meta.status || found.status || "open",
1553
+ path: found.path
1554
+ };
1555
+
1556
+ if (opts.json) {
1557
+ console.log(JSON.stringify(out, null, 2));
1558
+ } else {
1559
+ console.log(`ticket-context-ok ${out.id} | phase=${out.phase} | status=${out.status} | ${out.path}`);
1560
+ }
1561
+ return out;
1562
+ }
1563
+
1564
+ export async function runTicketHandoff(opts) {
1565
+ applyTicketRootContext(opts);
1566
+ if (!opts.topic && !opts.latest) opts.latest = true;
1567
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1568
+ const current = pickTicketEntry(opts, index);
1569
+ if (!current) throw new Error("ticket handoff: no matching ticket found");
1570
+
1571
+ const currentAbs = join(opts.cwd, current.path);
1572
+ const currentMissing = !existsSync(currentAbs);
1573
+ const currentBody = currentMissing ? "" : readFileSync(currentAbs, "utf8");
1574
+ const currentParsed = currentMissing ? { meta: {}, content: "" } : parseFrontMatter(currentBody);
1575
+ const currentPhase = Number(currentParsed.meta.phase || 1);
1576
+ const currentReasons = currentMissing ? ["ticket_file_missing"] : getPhase1IncompleteReasons(opts.cwd, currentAbs);
1577
+ const currentStatus = currentReasons.length > 0 && currentPhase === 1
1578
+ ? "phase1_incomplete"
1579
+ : (currentParsed.meta.status || current.status || "open");
1580
+
1581
+ const rows = filterTicketEntries(index.entries, opts)
1582
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
1583
+ let nextTicket = rows.find(e => e.status === "active" && e.id !== current.id);
1584
+ if (!nextTicket) nextTicket = rows.find(e => e.status === "open" && e.id !== current.id);
1585
+
1586
+ const out = {
1587
+ current: {
1588
+ id: current.id,
1589
+ phase: currentPhase,
1590
+ status: currentStatus,
1591
+ path: current.path,
1592
+ reasons: currentReasons
1593
+ },
1594
+ nextTicket: nextTicket ? {
1595
+ id: nextTicket.id,
1596
+ status: nextTicket.status,
1597
+ path: nextTicket.path
1598
+ } : null,
1599
+ nextAction: nextTicket ? "continue-ticket" : "inspect-git-history",
1600
+ telemetry: (() => {
1601
+ const summary = buildTelemetrySummary(opts.cwd);
1602
+ if (!summary) return null;
1603
+ return {
1604
+ logEntries: summary.logEntries,
1605
+ coverageRate: summary.eventCoverageRate,
1606
+ tdwCoverageRate: summary.tdwCoverageRate,
1607
+ totalTokens: summary.totalTokens
1608
+ };
1609
+ })(),
1610
+ telemetrySummary: getTelemetryCompactSummary(opts.cwd)
1611
+ };
1612
+
1613
+ if (opts.json) {
1614
+ console.log(JSON.stringify(out, null, 2));
1615
+ return out;
1616
+ }
1617
+
1618
+ if (isCompactTicketOutput(opts)) {
1619
+ console.log(getHandoffSummary(out));
1620
+ return out;
1621
+ }
1622
+
1623
+ console.log(`Current: ${out.current.id} | phase=${out.current.phase} | status=${out.current.status}`);
1624
+ console.log(`Next: ${out.nextTicket ? `${out.nextTicket.id} (${out.nextTicket.status})` : "none"}`);
1625
+ console.log(`Action: ${out.nextAction}`);
1626
+ return out;
1627
+ }
1628
+
1629
+ export async function runTicketMeta(opts) {
1630
+ applyTicketRootContext(opts);
1631
+ applyTicketContext(opts);
1632
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1633
+ const found = pickTicketEntry(opts, index);
1634
+ if (!found) throw new Error("ticket meta: no matching ticket found");
1635
+
1636
+ if (opts.json) {
1637
+ console.log(JSON.stringify(found, null, 2));
1638
+ } else {
1639
+ console.log(`Ticket Meta [${found.topic}]`);
1640
+ Object.entries(found).forEach(([k, v]) => console.log(` ${k}: ${v}`));
1641
+ }
1642
+ }
1643
+
1644
+ export async function runTicketConnect(opts) {
1645
+ applyTicketRootContext(opts);
1646
+ const config = loadInitConfig(opts.cwd);
1647
+ const url = opts.remote || config?.pipelineUrl;
1648
+ if (!url) throw new Error("ticket connect: no pipeline URL configured or provided via --remote");
1649
+
1650
+ console.log(`Connecting to AI Pipeline at ${url} ...`);
1651
+ const success = await syncToPipeline(url, { action: "ping", timestamp: new Date().toISOString() });
1652
+ if (success) {
1653
+ console.log("SUCCESS: Pipeline is reachable.");
1654
+ } else {
1655
+ console.error("FAILED: Could not connect to pipeline or returned non-OK status.");
1656
+ }
1657
+ }
1658
+
1659
+ export async function runTicketEvidenceCheck(opts) {
1660
+ applyTicketRootContext(opts);
1661
+ applyTicketContext(opts);
1662
+ if (!opts.claim || !String(opts.claim).trim()) {
1663
+ throw new Error("ticket evidence requires --claim <text> to compare with ticket content.");
1664
+ }
1665
+
1666
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1667
+ const target = pickTicketEntry(opts, index);
1668
+ if (!target) {
1669
+ throw new Error("ticket evidence: no matching ticket found.");
1670
+ }
1671
+
1672
+ const absPath = join(opts.cwd, target.path);
1673
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1674
+ const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1675
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1676
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1677
+
1678
+ if (!result.ok || !implementationGuard.ok) {
1679
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
1680
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.topic} has insufficient evidence coverage for claim "${opts.claim}": ${reasons.join(", ")}.`);
1681
+ }
1682
+
1683
+ if (opts.json) {
1684
+ console.log(JSON.stringify(result, null, 2));
1685
+ } else {
1686
+ console.log(`[evidence-ok] ${target.topic} claim coverage ${result.coveredTerms}/${result.claimTerms}`);
1687
+ }
1688
+ }
1689
+
1690
+ export async function runTicketEvidenceReport(opts) {
1691
+ applyTicketRootContext(opts);
1692
+ applyTicketContext(opts);
1693
+ if (!opts.claim || !String(opts.claim).trim()) {
1694
+ throw new Error("ticket report requires --claim <text> when generating a claim-bound report.");
1695
+ }
1696
+
1697
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1698
+ const target = pickTicketEntry(opts, index);
1699
+ if (!target) {
1700
+ throw new Error("ticket report: no matching ticket found.");
1701
+ }
1702
+
1703
+ const absPath = join(opts.cwd, target.path);
1704
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1705
+ const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1706
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1707
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1708
+
1709
+ if (!result.ok || !implementationGuard.ok) {
1710
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
1711
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.topic} cannot produce claim-bound report for "${opts.claim}": ${reasons.join(", ")}.`);
1712
+ }
1713
+
1714
+ if (opts.json) {
1715
+ console.log(JSON.stringify(result, null, 2));
1716
+ return;
1717
+ }
1718
+
1719
+ console.log(`Claim-bound ticket report: ${target.topic}`);
1720
+ console.log(`Claim: ${opts.claim}`);
1721
+ console.log(`Coverage: ${result.coveredTerms}/${result.claimTerms}`);
1722
+ for (const [label, value] of Object.entries(result.sections)) {
1723
+ if (!value) continue;
1724
+ console.log(`\n## ${label}`);
1725
+ console.log(value);
1726
+ }
1727
+ }
1728
+
1729
+
1730
+ export async function runTicketClose(opts) {
1731
+ applyTicketRootContext(opts);
1732
+ applyTicketContext(opts);
1733
+ if (!opts.topic && !opts.latest) {
1734
+ if (opts.nonInteractive || !process.stdout.isTTY) {
1735
+ opts.latest = true;
1736
+ } else {
1737
+ await withReadline(async (rl) => {
1738
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1739
+ const choices = index.entries
1740
+ .filter(e => e.status !== "closed" && e.status !== "cancelled")
1741
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
1742
+ if (choices.length > 0) {
1743
+ opts.topic = await selectOne(rl, "Choose a ticket to close:", choices);
1744
+ } else {
1745
+ throw new Error("No open tickets found to close.");
1746
+ }
1747
+ });
1748
+ }
1749
+ }
1750
+ // Respect --status flag (e.g. 'cancelled', 'wontfix'); default to 'closed'
1751
+ if (!opts.status) opts.status = "closed";
1752
+ const previousIndex = readTicketIndexJson(opts.cwd);
1753
+ const targetEntry = pickTicketEntry(opts, previousIndex);
1754
+ if (!targetEntry) {
1755
+ throw new Error("No matching ticket found to update status");
1756
+ }
1757
+
1758
+ const abs = join(opts.cwd, targetEntry.path);
1759
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + targetEntry.path);
1760
+ const previousBody = readFileSync(abs, "utf8");
1761
+ const parsedForClose = parseFrontMatter(previousBody);
1762
+ const closePlanningReasons = getCloseLifecycleReasons(parsedForClose.meta, parsedForClose.content);
1763
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { content: parsedForClose.content, changedFiles: opts.changedFiles });
1764
+ if (!implementationGuard.ok) {
1765
+ closePlanningReasons.push(...implementationGuard.reasons);
1766
+ }
1767
+ if (closePlanningReasons.length) {
1768
+ throw new Error(`[VALIDATION FAILED] Ticket ${targetEntry.topic} cannot close without complete main-ticket analysis/design evidence: ${[...new Set(closePlanningReasons)].join(", ")}.`);
1769
+ }
1770
+
1771
+ try {
1772
+ const entry = updateTicketEntryStatus(opts.cwd, opts);
1773
+ const { meta } = parseFrontMatter(previousBody);
1774
+ runTicketLifecycleQualityGate(opts.cwd, {
1775
+ ticketAbsPath: abs,
1776
+ context: `ticket close ${entry.topic}`
1777
+ });
1778
+ syncActiveTicketId(opts.cwd);
1779
+ appendTelemetryEvent(opts.cwd, {
1780
+ event: "ticket_closed",
1781
+ action: "ticket-close",
1782
+ ticket: entry.id || entry.topic,
1783
+ file: entry.path,
1784
+ phase: 4,
1785
+ status: opts.status
1786
+ });
1787
+ console.log(`ticket: ${opts.status} -> ${entry.topic} (${entry.path})`);
1788
+ } catch (err) {
1789
+ rollbackTicketLifecycleArtifacts(opts.cwd, previousIndex, previousBody, abs, opts);
1790
+ throw err;
1791
+ }
1792
+ }
1793
+
1794
+ export async function runTicketUse(opts) {
1795
+ applyTicketRootContext(opts);
1796
+ applyTicketContext(opts);
1797
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1798
+ const scopedEntries = filterTicketEntries(index.entries, opts);
1799
+
1800
+ let targetTopic = opts.topic;
1801
+ if (!targetTopic && !opts.latest) {
1802
+ if (opts.nonInteractive) {
1803
+ throw new Error("ticket use: --topic or --latest is required in non-interactive mode.");
1804
+ }
1805
+ await withReadline(async (rl) => {
1806
+ const choices = scopedEntries
1807
+ .map(e => ({ label: `${e.status === 'closed' ? '✓ ' : ''}[${e.group}] ${e.title}`, value: e.topic }));
1808
+ if (choices.length > 0) {
1809
+ targetTopic = await selectOne(rl, "Choose a ticket to use:", choices);
1810
+ }
1811
+ });
1812
+ }
1813
+
1814
+ const found = opts.latest ? scopedEntries[0] : scopedEntries.find(e =>
1815
+ String(e.topic || "").includes(targetTopic) ||
1816
+ String(e.id || "").includes(targetTopic)
1817
+ );
1818
+ if (!found) {
1819
+ const candidates = buildUseFallbackCandidates(index, opts);
1820
+ if (!opts.nonInteractive && candidates.length > 0) {
1821
+ await withReadline(async (rl) => {
1822
+ targetTopic = await selectOne(
1823
+ rl,
1824
+ `No matching ticket found for "${targetTopic}". Choose a ticket to use:`,
1825
+ candidates.map(e => ({ label: formatTicketChoice(e), value: e.topic || e.id }))
1826
+ );
1827
+ });
1828
+ const selected = index.entries.find(e => e.topic === targetTopic || e.id === targetTopic);
1829
+ if (selected) {
1830
+ opts.topic = targetTopic;
1831
+ return runTicketUse({ ...opts, latest: false });
1832
+ }
1833
+ }
1834
+ throw new Error(buildUseNoMatchError(targetTopic, candidates));
1835
+ }
1836
+
1837
+ const foundAbsPath = join(opts.cwd, found.path);
1838
+ if (!existsSync(foundAbsPath)) throw new Error("Ticket file not found: " + found.path);
1839
+ const foundParsed = parseFrontMatter(readFileSync(foundAbsPath, "utf8"));
1840
+ assertTicketLifecycleProvenance(found, foundParsed.meta);
1841
+
1842
+ // Explicitly set activeTicketId to the selected ticket
1843
+ if (index.activeTicketId !== found.id) {
1844
+ writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
1845
+ }
1846
+
1847
+ const absPath = toPosixPath(join(opts.cwd, found.path));
1848
+ if (isCompactTicketOutput(opts) || opts.pathOnly) {
1849
+ printTicketSelectionLine(found.id, absPath, opts);
1850
+ } else {
1851
+ const posixPath = toPosixPath(found.path);
1852
+ console.log(`Active ticket: ${found.id}`);
1853
+ console.log(`Path: [${posixPath}](file://${absPath})`);
1854
+ printTicketStartLine(found.id, absPath);
1855
+ if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
1856
+ printUsageReminder(opts.cwd, opts);
1857
+ }
1858
+ }
1859
+
1860
+
1861
+
1862
+ function extractMarkdownSections(content, sectionNames) {
1863
+ const sections = {};
1864
+ for (const name of sectionNames) {
1865
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1866
+ const regex = new RegExp(`^##\\s+${escapedName}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
1867
+ const match = content.match(regex);
1868
+ if (match) {
1869
+ const value = match[1].trim();
1870
+ if (value) sections[name] = value;
1871
+ }
1872
+ }
1873
+ return sections;
1874
+ }
1875
+
1876
+ function distillKnowledge(absPath, ticketId, cwd, sourceBody = null) {
1877
+ try {
1878
+ const body = sourceBody !== null ? sourceBody : readFileSync(absPath, "utf8");
1879
+ const { meta, content } = parseFrontMatter(body);
1880
+ const ticketSections = extractMarkdownSections(content, [
1881
+ "Scope & Constraints",
1882
+ "Agent Permission Contract (APC)",
1883
+ "Compact Plan",
1884
+ "Tasks",
1885
+ "Done When",
1886
+ "Design Decisions",
1887
+ "Analysis & Constraints"
1888
+ ]);
1889
+ const knowledgeDir = join(cwd, AGENT_ROOT_DIR, "knowledge");
1890
+ if (!existsSync(knowledgeDir)) mkdirSync(knowledgeDir, { recursive: true });
1891
+
1892
+ const dest = join(knowledgeDir, `${ticketId}.json`);
1893
+ const data = {
1894
+ id: ticketId,
1895
+ title: meta.title || ticketId,
1896
+ project: meta.project || "global",
1897
+ createdAt: meta.createdAt,
1898
+ archivedAt: new Date().toISOString(),
1899
+ summary: meta.summary || "",
1900
+ sourceKind: "ticket",
1901
+ ingestionCategory: "archived_ticket",
1902
+ corpus: "tickets",
1903
+ originTool: "ticket-archive",
1904
+ freshness: "archived",
1905
+ refreshPolicy: "refresh-on-stale",
1906
+ sourceTicketPath: toRepoRelativePath(cwd, absPath),
1907
+ sections: ticketSections,
1908
+ analysis: {}
1909
+ };
1910
+
1911
+ writeFileSync(dest, JSON.stringify(data, null, 2), "utf8");
1912
+ console.log(`Knowledge distilled to ${toFileUri(dest)}`);
1913
+ appendTelemetryEvent(cwd, {
1914
+ event: "knowledge_distilled",
1915
+ action: "knowledge-distill",
1916
+ ticket: ticketId,
1917
+ file: toRepoRelativePath(cwd, absPath),
1918
+ knowledgeAction: "add_knowledge",
1919
+ knowledgeSourceKind: data.sourceKind,
1920
+ knowledgeIngestionCategory: data.ingestionCategory,
1921
+ knowledgeCorpus: data.corpus,
1922
+ knowledgeOriginTool: data.originTool,
1923
+ knowledgeFreshness: data.freshness,
1924
+ tokenQuality: "saved"
1925
+ });
1926
+ } catch (err) {
1927
+ console.warn(`[WARNING] Knowledge distillation failed for ${ticketId}: ${err.message}`);
1928
+ }
1929
+ }
1930
+
1931
+ function appendTelemetryEvent(cwd, entry) {
1932
+ try {
1933
+ appendInternalWorkflowEvent(cwd, {
1934
+ event: entry.event || "workflow_event",
1935
+ ticket: entry.ticket || "",
1936
+ action: entry.action || entry.event || "workflow-event",
1937
+ file: entry.file || "",
1938
+ phase: entry.phase,
1939
+ status: entry.status || "",
1940
+ ragResult: entry.ragResult || "",
1941
+ localFallback: Boolean(entry.localFallback),
1942
+ knowledgeAction: entry.knowledgeAction || "",
1943
+ knowledgeSourceKind: entry.knowledgeSourceKind || "",
1944
+ knowledgeIngestionCategory: entry.knowledgeIngestionCategory || "",
1945
+ knowledgeCorpus: entry.knowledgeCorpus || "",
1946
+ knowledgeOriginTool: entry.knowledgeOriginTool || "",
1947
+ knowledgeFreshness: entry.knowledgeFreshness || "",
1948
+ tokenQuality: entry.tokenQuality || "",
1949
+ savedTokens: Number(entry.savedTokens || 0)
1950
+ });
1951
+ } catch (err) {
1952
+ console.warn(`[WARNING] Telemetry append failed for ${entry.ticket || "unknown"}: ${err.message}`);
1953
+ }
1954
+ }
1955
+
1956
+ export function pickTicketEntry(opts, indexJson) {
1957
+ const rows = filterTicketEntries(indexJson.entries, opts)
1958
+ .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
1959
+ if (rows.length === 0) return null;
1960
+ if (opts.topic) {
1961
+ const key = String(opts.topic).toLowerCase();
1962
+ return rows.find(e =>
1963
+ String(e.topic || "").toLowerCase().includes(key) ||
1964
+ String(e.id || "").toLowerCase().includes(key)
1965
+ ) || null;
1966
+ }
1967
+ return rows[0];
1968
+ }
1969
+
1970
+ function filterTicketEntries(entries, opts = {}) {
1971
+ return [...(entries || [])].filter(entry => {
1972
+ if (opts.project && entry.project !== opts.project) return false;
1973
+ if (opts.submodule && entry.submodule !== opts.submodule) return false;
1974
+ return true;
1975
+ });
1976
+ }
1977
+
1978
+ export async function runTicketArchive(opts) {
1979
+ applyTicketRootContext(opts);
1980
+ applyTicketContext(opts);
1981
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1982
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
1983
+
1984
+ if (!opts.latest && !opts.topic) {
1985
+ if (opts.nonInteractive) {
1986
+ throw new Error("ticket archive: --topic or --latest is required in non-interactive mode.");
1987
+ }
1988
+ await withReadline(async (rl) => {
1989
+ const choices = indexJson.entries
1990
+ .filter(e => e.status !== "archived")
1991
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
1992
+ if (choices.length > 0) {
1993
+ opts.topic = await selectOne(rl, "Choose a ticket to archive (this will move the file to archive/):", choices);
1994
+ } else {
1995
+ throw new Error("No active tickets found to archive.");
1996
+ }
1997
+ });
1998
+ }
1999
+
2000
+ const found = pickTicketEntry(opts, indexJson);
2001
+ if (!found) throw new Error("ticket archive: no matching entry");
2002
+
2003
+ const fileName = found.path.split(/[/\\]/).pop();
2004
+
2005
+ // Auto-search for report if not provided
2006
+ let report = opts.report;
2007
+ if (!opts.report) {
2008
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2009
+ const potentialReport = fileName.replace(/\.md$/i, "-report.md");
2010
+ const potentialPath = join(reportDir, potentialReport);
2011
+ if (existsSync(potentialPath)) {
2012
+ report = toRepoRelativePath(opts.cwd, potentialPath);
2013
+ console.log(`ticket archive: auto-detected report at ${report}`);
2014
+ }
2015
+ }
2016
+
2017
+ const result = archiveTicketEntry({ cwd: opts.cwd, ticketDir, indexJson, found, opts, report });
2018
+ if (opts.dryRun) return;
2019
+
2020
+ writeTicketIndexJson(opts.cwd, indexJson, opts);
2021
+ syncActiveTicketId(opts.cwd);
2022
+ if (result?.id) {
2023
+ appendTelemetryEvent(opts.cwd, {
2024
+ event: "ticket_archived",
2025
+ action: "ticket-archive",
2026
+ ticket: result.id,
2027
+ file: result.path,
2028
+ status: "archived"
2029
+ });
2030
+ }
2031
+ return result;
2032
+ }
2033
+
2034
+ export async function runTicketDiscard(opts) {
2035
+ applyTicketRootContext(opts);
2036
+ applyTicketContext(opts);
2037
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2038
+
2039
+ if (!opts.latest && !opts.topic) {
2040
+ if (opts.nonInteractive) {
2041
+ throw new Error("ticket discard: --topic or --latest is required in non-interactive mode.");
2042
+ }
2043
+ await withReadline(async (rl) => {
2044
+ const choices = indexJson.entries
2045
+ .filter(e => e.status === "open")
2046
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
2047
+ if (choices.length > 0) {
2048
+ opts.topic = await selectOne(rl, "Choose an unapproved ticket to discard:", choices);
2049
+ } else {
2050
+ throw new Error("No open tickets found to discard.");
2051
+ }
2052
+ });
2053
+ }
2054
+
2055
+ const found = pickTicketEntry(opts, indexJson);
2056
+ if (!found) throw new Error("ticket discard: no matching entry");
2057
+
2058
+ const absPath = join(opts.cwd, found.path);
2059
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + found.path);
2060
+
2061
+ const body = readFileSync(absPath, "utf8");
2062
+ const { meta } = parseFrontMatter(body);
2063
+ const phase = Number(meta.phase || 1);
2064
+ const status = String(meta.status || found.status || "open").toLowerCase();
2065
+ if (phase >= 2 || status !== "open") {
2066
+ throw new Error(`ticket discard: ${found.id || found.topic} is not an unapproved Phase 1 open ticket. Use ticket close/archive for approved or executed work.`);
2067
+ }
2068
+
2069
+ if (opts.dryRun) {
2070
+ console.log(`ticket discard: would delete ${found.id || found.topic} (${found.path})`);
2071
+ return { id: found.id, path: found.path, discarded: false };
2072
+ }
2073
+
2074
+ rmSync(absPath, { force: true });
2075
+ const nextEntries = (indexJson.entries || []).filter(entry => entry.id !== found.id);
2076
+ writeTicketIndexJson(opts.cwd, {
2077
+ ...indexJson,
2078
+ activeTicketId: indexJson.activeTicketId === found.id ? null : indexJson.activeTicketId,
2079
+ entries: nextEntries
2080
+ }, opts);
2081
+ syncActiveTicketId(opts.cwd);
2082
+ appendTelemetryEvent(opts.cwd, {
2083
+ event: "ticket_discarded",
2084
+ action: "ticket-discard",
2085
+ ticket: found.id || found.topic,
2086
+ file: found.path,
2087
+ phase,
2088
+ status: "discarded"
2089
+ });
2090
+ console.log(`ticket: discarded -> ${found.id || found.topic}`);
2091
+ return { id: found.id, path: found.path, discarded: true };
2092
+ }
2093
+
2094
+ export async function runTicketReports(opts) {
2095
+ applyTicketRootContext(opts);
2096
+ const ticketDir = detectConsumerTicketDir(opts.cwd);
2097
+ if (!ticketDir) throw new Error("No ticket system found.");
2098
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2099
+ console.log("\n📄 Agent Reports:");
2100
+ if (!existsSync(reportDir)) {
2101
+ console.log(" No reports found.");
2102
+ return;
2103
+ }
2104
+ const files = readdirSync(reportDir).filter(f => f.endsWith("-report.md"));
2105
+ if (files.length === 0) {
2106
+ console.log(" No reports found.");
2107
+ return;
2108
+ }
2109
+
2110
+ const index = readTicketIndexJson(opts.cwd);
2111
+ const sorted = files.sort((a, b) => {
2112
+ return statSync(join(reportDir, b)).mtime.getTime() - statSync(join(reportDir, a)).mtime.getTime();
2113
+ });
2114
+
2115
+ sorted.slice(0, opts.limit || 30).forEach(f => {
2116
+ const ticketId = f.replace(/-report\.md$/i, "");
2117
+ const entry = index.entries.find(e => e.id === ticketId || e.topic === ticketId);
2118
+ const status = entry ? ` [${entry.status}]` : "";
2119
+ console.log(` - [${f}](${toFileUri(join(reportDir, f))})${status}`);
2120
+ });
2121
+ console.log("");
2122
+ }
2123
+
2124
+ export async function runTicketReportAttach(opts) {
2125
+ applyTicketRootContext(opts);
2126
+ applyTicketContext(opts);
2127
+ if (!opts.report) throw new Error("ticket report attach requires --report <file_path>");
2128
+
2129
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2130
+ const found = pickTicketEntry(opts, index);
2131
+ if (!found) throw new Error("ticket report attach: no matching ticket found");
2132
+
2133
+ const absTicketPath = join(opts.cwd, found.path);
2134
+ if (!existsSync(absTicketPath)) throw new Error("Ticket file not found: " + found.path);
2135
+
2136
+ const reportSrc = resolve(opts.cwd, opts.report);
2137
+ if (!existsSync(reportSrc)) throw new Error("Report file not found: " + opts.report);
2138
+
2139
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2140
+ if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
2141
+
2142
+ const ticketFileName = found.path.split(/[/\\]/).pop();
2143
+ const reportBaseName = ticketFileName.replace(/\.md$/i, "-report.md");
2144
+ const reportDest = join(reportDir, reportBaseName);
2145
+
2146
+ if (!opts.dryRun) {
2147
+ copyFileSync(reportSrc, reportDest);
2148
+
2149
+ // Update ticket content to link the report
2150
+ let body = readFileSync(absTicketPath, "utf8").trimEnd();
2151
+ if (!body.includes("## 📄 Attached Report")) {
2152
+ const relativeLink = toPosixPath(relative(dirname(absTicketPath), reportDest));
2153
+ body += `\n\n## 📄 Attached Report\n- [View Report](${relativeLink})\n`;
2154
+ writeFileSync(absTicketPath, body, "utf8");
2155
+ }
2156
+ console.log(`ticket report: attached ${toRepoRelativePath(opts.cwd, reportSrc)} to ${found.id}`);
2157
+ } else {
2158
+ console.log(`ticket report: would attach ${toRepoRelativePath(opts.cwd, reportSrc)} to ${found.id}`);
2159
+ }
2160
+ }
2161
+
2162
+ export async function runTicketRebuild(opts) {
2163
+ applyTicketRootContext(opts, { createIfMissing: true });
2164
+ console.log("Rebuilding INDEX.json from markdown files...");
2165
+ rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: true, rebuild: true });
2166
+ }
2167
+
2168
+ export async function runTicketMove(opts) {
2169
+ applyTicketRootContext(opts);
2170
+ applyTicketContext(opts);
2171
+ if (!opts.topic && !opts.latest) {
2172
+ if (opts.nonInteractive) {
2173
+ throw new Error("ticket move: --topic or --latest is required in non-interactive mode.");
2174
+ }
2175
+ opts.latest = true; // Default to latest
2176
+ }
2177
+
2178
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2179
+ const entry = pickTicketEntry(opts, index);
2180
+
2181
+ if (!entry) throw new Error("No matching ticket found to move.");
2182
+
2183
+ const abs = join(opts.cwd, entry.path);
2184
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
2185
+
2186
+ const previousIndex = readTicketIndexJson(opts.cwd);
2187
+ const body = readFileSync(abs, "utf8");
2188
+ const { meta, content } = parseFrontMatter(body);
2189
+
2190
+ const currentPhase = meta.phase || 1;
2191
+ let nextPhase = opts.next ? currentPhase + 1 : (opts.phase || currentPhase + 1);
2192
+
2193
+ if (currentPhase === 1 && nextPhase >= 2) {
2194
+ const reasons = getPhase1IncompleteReasons(opts.cwd, abs);
2195
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { content, changedFiles: opts.changedFiles });
2196
+ if (!implementationGuard.ok) {
2197
+ reasons.push(...implementationGuard.reasons);
2198
+ }
2199
+ if (reasons.length) {
2200
+ throw new Error(`[VALIDATION FAILED] Ticket ${entry.topic} has incomplete Phase 1 planning evidence: ${reasons.join(", ")}. Fill substantive APC and compact plan content before moving to Phase 2.`);
2201
+ }
2202
+ if (!hasExplicitExecutionApproval(opts)) {
2203
+ throw new Error(`[APPROVAL REQUIRED] Ticket ${entry.topic} 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.`);
2204
+ }
2205
+ }
2206
+
2207
+ meta.phase = nextPhase;
2208
+ if (nextPhase >= 4) {
2209
+ meta.status = "closed";
2210
+ } else if (nextPhase >= 2 && (!meta.status || meta.status === "open")) {
2211
+ meta.status = "active";
2212
+ }
2213
+
2214
+ const newBody = stringifyFrontMatter(meta, content);
2215
+
2216
+ try {
2217
+ writeFileSync(abs, newBody, "utf8");
2218
+
2219
+ // Re-sync index to reflect the status change if any
2220
+ opts.topic = entry.topic;
2221
+ if (meta.status !== entry.status) {
2222
+ opts.status = meta.status;
2223
+ updateTicketEntryStatus(opts.cwd, opts);
2224
+ } else {
2225
+ rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: true });
2226
+ }
2227
+
2228
+ runTicketLifecycleQualityGate(opts.cwd, {
2229
+ ticketAbsPath: abs,
2230
+ context: `ticket move ${entry.topic}`
2231
+ });
2232
+
2233
+ syncActiveTicketId(opts.cwd);
2234
+ appendTelemetryEvent(opts.cwd, {
2235
+ event: "ticket_phase_moved",
2236
+ action: "ticket-move",
2237
+ ticket: entry.id || entry.topic,
2238
+ file: entry.path,
2239
+ phase: nextPhase,
2240
+ status: meta.status
2241
+ });
2242
+ console.log(`ticket: moved -> ${entry.topic} is now in Phase ${nextPhase} (${meta.status})`);
2243
+ printUsageReminder(opts.cwd, opts);
2244
+ } catch (err) {
2245
+ rollbackTicketLifecycleArtifacts(opts.cwd, previousIndex, body, abs, opts);
2246
+ throw err;
2247
+ }
2248
+ }
2249
+
2250
+ export async function runTicketNext(opts) {
2251
+ applyTicketRootContext(opts);
2252
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2253
+ // Find the first active ticket, or if none, the first open ticket (earliest created)
2254
+ const rows = filterTicketEntries(index.entries, opts)
2255
+ .filter(entry => isTicketNextRunnableCandidate(opts.cwd, entry))
2256
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
2257
+ let found = rows.find(e => e.status === "active");
2258
+ if (!found) {
2259
+ found = rows.find(e => e.status === "open");
2260
+ }
2261
+
2262
+ if (!found) {
2263
+ const latestClosed = filterTicketEntries(index.entries, opts)
2264
+ .filter(entry => String(entry.status || "").toLowerCase() === "closed")
2265
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0];
2266
+ if (latestClosed) {
2267
+ const latestClosedPath = join(opts.cwd, latestClosed.path || computeTicketPath(latestClosed));
2268
+ if (existsSync(latestClosedPath)) {
2269
+ const { content } = parseFrontMatter(readFileSync(latestClosedPath, "utf8"));
2270
+ if (followUpDecisionMeansNoFollowUp(content)) {
2271
+ const absPath = toPosixPath(latestClosedPath);
2272
+ if (opts.pathOnly) {
2273
+ console.log(`no-follow-up:${latestClosed.id}`);
2274
+ } else if (isCompactTicketOutput(opts)) {
2275
+ console.log(`no-follow-up:${latestClosed.id}`);
2276
+ } else {
2277
+ const posixPath = toPosixPath(latestClosed.path || computeTicketPath(latestClosed));
2278
+ console.log(`No follow-up required after ${latestClosed.id}`);
2279
+ console.log(`Path: [${posixPath}](file://${absPath})`);
2280
+ }
2281
+ return { status: "no-follow-up", ticket: latestClosed.id, path: latestClosed.path || computeTicketPath(latestClosed) };
2282
+ }
2283
+ }
2284
+ }
2285
+ throw new Error("No active or open tickets found to proceed with. Inspect recent git history before creating a follow-up ticket.");
2286
+ }
2287
+
2288
+ if (index.activeTicketId !== found.id) {
2289
+ writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
2290
+ }
2291
+
2292
+ const absPath = toPosixPath(join(opts.cwd, found.path));
2293
+ if (isCompactTicketOutput(opts) || opts.pathOnly) {
2294
+ printTicketSelectionLine(found.id, absPath, opts);
2295
+ } else {
2296
+ const posixPath = toPosixPath(found.path);
2297
+ console.log(`Next ticket: ${found.id}`);
2298
+ console.log(`Path: [${posixPath}](file://${absPath})`);
2299
+ if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
2300
+ }
2301
+ }
2302
+
2303
+ function isTicketNextRunnableCandidate(cwd, entry) {
2304
+ const entryPath = entry.path || computeTicketPath(entry);
2305
+ const absPath = join(cwd, entryPath);
2306
+ if (!existsSync(absPath)) return true;
2307
+
2308
+ const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
2309
+ const lifecycleSource = String(meta.lifecycleSource || meta.ticketLifecycleSource || "").trim();
2310
+ return lifecycleSource === "ticket-create";
2311
+ }
2312
+
2313
+ export async function runTicketHotfix(opts) {
2314
+ applyTicketRootContext(opts);
2315
+ if (!opts.topic && !opts.latest) {
2316
+ if (opts.nonInteractive) {
2317
+ throw new Error("ticket hotfix: --topic or --latest is required in non-interactive mode.");
2318
+ }
2319
+ opts.latest = true;
2320
+ }
2321
+
2322
+ if (!opts.reason) {
2323
+ throw new Error("[HOTFIX DENIED] A mandatory --reason must be provided to justify bypassing standard rules (e.g., 'codegen is broken').");
2324
+ }
2325
+
2326
+ // User explicit approval
2327
+ if (!opts.nonInteractive) {
2328
+ let proceed = false;
2329
+ await withReadline(async (rl) => {
2330
+ proceed = await new Promise(resolve => {
2331
+ rl.question(`\n⚠️ [EMERGENCY HOTFIX] This will bypass standard APC rules.\nReason: ${opts.reason}\nProceed? (y/N): `, a => {
2332
+ resolve(a.trim().toLowerCase() === 'y');
2333
+ });
2334
+ });
2335
+ });
2336
+ if (!proceed) {
2337
+ console.log('Hotfix cancelled by user.');
2338
+ return;
2339
+ }
2340
+ }
2341
+
2342
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2343
+ const entry = pickTicketEntry(opts, index);
2344
+
2345
+ if (!entry) throw new Error("No matching ticket found for hotfix.");
2346
+
2347
+ const abs = join(opts.cwd, entry.path);
2348
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
2349
+
2350
+ const body = readFileSync(abs, "utf8");
2351
+ const { meta, content } = parseFrontMatter(body);
2352
+
2353
+ // Force phase 2 and active status, bypassing APC checks
2354
+ meta.phase = 2;
2355
+ meta.status = "active";
2356
+ meta.hotfix = true;
2357
+ meta.hotfixReason = opts.reason;
2358
+
2359
+ // Append hotfix record to content
2360
+ const timestamp = new Date().toISOString();
2361
+ const hotfixRecord = `\n\n> [!WARNING]\n> **EMERGENCY HOTFIX ACTIVATED** (${timestamp})\n> **Reason:** ${opts.reason}\n> Standard APC and Phase 1 guards were bypassed.\n`;
2362
+
2363
+ const newBody = stringifyFrontMatter(meta, content + hotfixRecord);
2364
+ writeFileSync(abs, newBody, "utf8");
2365
+
2366
+ // Re-sync index
2367
+ opts.topic = entry.topic;
2368
+ opts.status = "active";
2369
+ updateTicketEntryStatus(opts.cwd, opts);
2370
+
2371
+ syncActiveTicketId(opts.cwd);
2372
+ console.log(`[EMERGENCY HOTFIX] Ticket ${entry.topic} is now ACTIVE. Rule guardrails bypassed for this session.`);
2373
+
2374
+ // Auto-create derivation ticket
2375
+ const deriveTopic = `codegen-fix-${entry.topic}`;
2376
+ const deriveSummary = `[DERIVED] Fix CodeGen source for hotfix: ${opts.reason}`;
2377
+ console.log(`[HOTFIX] Auto-creating derivation ticket: ${deriveTopic}`);
2378
+
2379
+ try {
2380
+ await runTicketCreate({
2381
+ cwd: opts.cwd,
2382
+ topic: deriveTopic,
2383
+ summary: deriveSummary,
2384
+ chain: true,
2385
+ tags: 'hotfix-derived,codegen',
2386
+ priority: 'P1',
2387
+ skipPhase0: true,
2388
+ nonInteractive: true
2389
+ });
2390
+ } catch (err) {
2391
+ console.warn(`[WARNING] Failed to auto-create derivation ticket: ${err.message}`);
2392
+ }
2393
+ }