deuk-agent-rule 2.5.13 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.ko.md +74 -0
  2. package/CHANGELOG.md +138 -316
  3. package/README.ko.md +134 -154
  4. package/README.md +121 -153
  5. package/package.json +29 -7
  6. package/scripts/cli-args.mjs +87 -3
  7. package/scripts/cli-init-commands.mjs +1382 -223
  8. package/scripts/cli-init-logic.mjs +28 -16
  9. package/scripts/cli-prompts.mjs +13 -4
  10. package/scripts/cli-rule-compiler.mjs +44 -34
  11. package/scripts/cli-skill-commands.mjs +172 -0
  12. package/scripts/cli-telemetry-commands.mjs +429 -0
  13. package/scripts/cli-ticket-commands.mjs +1934 -161
  14. package/scripts/cli-ticket-index.mjs +298 -0
  15. package/scripts/cli-ticket-migration.mjs +320 -0
  16. package/scripts/cli-ticket-parser.mjs +207 -0
  17. package/scripts/cli-utils.mjs +381 -59
  18. package/scripts/cli.mjs +99 -19
  19. package/scripts/lint-md.mjs +247 -0
  20. package/scripts/lint-rules.mjs +143 -0
  21. package/scripts/merge-logic.mjs +13 -306
  22. package/scripts/plan-parser.mjs +53 -0
  23. package/templates/MODULE_RULE_TEMPLATE.md +11 -0
  24. package/templates/PROJECT_RULE.md +47 -0
  25. package/templates/TICKET_TEMPLATE.ko.md +21 -0
  26. package/templates/TICKET_TEMPLATE.md +21 -0
  27. package/templates/rules.d/deukcontext-mcp.md +31 -0
  28. package/templates/rules.d/platform-coexistence.md +29 -0
  29. package/templates/skills/context-recall/SKILL.md +25 -0
  30. package/templates/skills/generated-file-guard/SKILL.md +25 -0
  31. package/templates/skills/safe-refactor/SKILL.md +25 -0
  32. package/bundle/.cursorrules +0 -11
  33. package/bundle/AGENTS.md +0 -146
  34. package/bundle/gemini.md +0 -26
  35. package/bundle/rules/delivery-and-parallel-work.mdc +0 -26
  36. package/bundle/rules/git-commit.mdc +0 -24
  37. package/bundle/rules/multi-ai-workflow.mdc +0 -104
  38. package/bundle/rules.d/core-workflow.md +0 -48
  39. package/bundle/rules.d/deukrag-mcp.md +0 -37
  40. package/bundle/templates/MODULE_RULE_TEMPLATE.md +0 -24
  41. package/bundle/templates/TICKET_TEMPLATE.md +0 -58
  42. package/scripts/cli-ticket-logic.mjs +0 -568
  43. package/scripts/sync-bundle.mjs +0 -77
  44. package/scripts/sync-oss.mjs +0 -126
@@ -2,114 +2,1332 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFil
2
2
  import { hostname } from "os";
3
3
  import { basename, join, dirname, relative, resolve } from "path";
4
4
  import {
5
- toSlug, toRepoRelativePath, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter,
6
- AGENT_ROOT_DIR, TICKET_SUBDIR, TEMPLATE_SUBDIR, TICKET_DIR_NAME
5
+ toSlug, toRepoRelativePath, toFileUri, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter,
6
+ resolveDocsLanguage, inferDocsLanguageFromText, normalizeDocsLanguage, isMcpActive, withReadline, parseFrontMatter,
7
+ AGENT_ROOT_DIR, TICKET_SUBDIR, TEMPLATE_SUBDIR, TICKET_DIR_NAME, TICKET_INDEX_FILENAME, detectConsumerTicketDir, loadInitConfig, computeTicketPath
7
8
  } from "./cli-utils.mjs";
8
- import {
9
- appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, detectConsumerTicketDir,
10
- readTicketIndexJson, writeTicketIndexJson, writeTicketListFile, syncActiveTicketId,
11
- generateTicketId, syncToPipeline, updateTicketEntryStatus
12
- } from "./cli-ticket-logic.mjs";
13
- import { loadInitConfig } from "./cli-utils.mjs";
9
+ import { readTicketIndexJson, writeTicketIndexJson, syncActiveTicketId, generateTicketId, syncToPipeline } from "./cli-ticket-index.mjs";
10
+ import { appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, updateTicketEntryStatus } from "./cli-ticket-parser.mjs";
11
+ import { appendInternalWorkflowEvent } from "./cli-telemetry-commands.mjs";
12
+ import { parsePlan } from "./plan-parser.mjs";
13
+ import { collectChangedFiles, collectChangedMarkdownFiles, lintMarkdownPaths } from "./lint-md.mjs";
14
14
  import ejs from "ejs";
15
+ import YAML from "yaml";
15
16
 
16
17
  import { createInterface } from "readline";
17
18
  import { selectOne } from "./cli-prompts.mjs";
18
19
 
20
+ const MAX_OPEN_TICKETS = 20;
21
+ const OPEN_TICKET_STATUSES = new Set(["open", "active"]);
22
+ const AUTO_ARCHIVE_DONE_STATUSES = new Set(["closed", "cancelled", "wontfix"]);
23
+
24
+ async function ensurePhase0Validation(opts) {
25
+ if (!opts.evidence && !opts.skipPhase0) {
26
+ // No more interactive prompts. Default to skip if no evidence provided.
27
+ opts.skipPhase0 = true;
28
+ }
29
+
30
+ if (opts.skipPhase0) {
31
+ try {
32
+ if (!isCompactTicketOutput(opts) && await isMcpActive(opts.cwd)) {
33
+ console.warn("[WARNING] Phase 0 RAG evidence is recommended when the MCP server is active. Proceeding without evidence as requested.");
34
+ }
35
+ } catch (err) {
36
+ // MCP detection failure should not block ticket creation
37
+ if (process.env.DEBUG) console.warn("[DEBUG] MCP activation check failed:", err.message);
38
+ }
39
+ }
40
+ }
41
+
42
+ function resolveTicketTemplate(cwd, docsLanguageInput, promptText = "") {
43
+ const config = loadInitConfig(cwd) || {};
44
+ const explicitDocsLanguage = normalizeDocsLanguage(docsLanguageInput);
45
+ const configDocsLanguage = normalizeDocsLanguage(config.docsLanguage || "auto");
46
+ const promptDocsLanguage = explicitDocsLanguage === "auto" && configDocsLanguage === "auto"
47
+ ? inferDocsLanguageFromText(promptText)
48
+ : null;
49
+ const docsLanguage = resolveDocsLanguage(
50
+ explicitDocsLanguage !== "auto"
51
+ ? explicitDocsLanguage
52
+ : configDocsLanguage !== "auto"
53
+ ? configDocsLanguage
54
+ : promptDocsLanguage || "en"
55
+ );
56
+
57
+ const templateDir = join(cwd, AGENT_ROOT_DIR, TEMPLATE_SUBDIR);
58
+ const bundleTplDir = join(new URL(".", import.meta.url).pathname, "..", "templates");
59
+
60
+ const ticketTemplateCandidates = [
61
+ join(templateDir, `TICKET_TEMPLATE.${docsLanguage}.md`),
62
+ join(bundleTplDir, `TICKET_TEMPLATE.${docsLanguage}.md`),
63
+ join(templateDir, "TICKET_TEMPLATE.md"),
64
+ join(bundleTplDir, "TICKET_TEMPLATE.md"),
65
+ ];
66
+ const ticketTemplatePath = ticketTemplateCandidates.find(p => existsSync(p));
67
+ if (!ticketTemplatePath) {
68
+ throw new Error("ticket create: Template not found. Please run 'npx deuk-agent-rule init' to deploy templates.");
69
+ }
70
+ return { tplText: readFileSync(ticketTemplatePath, "utf8"), docsLanguage };
71
+ }
72
+
73
+ function hasPlaceholderTokens(text) {
74
+ const src = String(text || "").toLowerCase();
75
+ return src.includes("[add ") || src.includes("[fill") || src.includes("placeholder") || src.includes("todo") || src.includes("tbd");
76
+ }
77
+
78
+ const PLAN_SCAFFOLD_PHRASES = [
79
+ "Describe what is actually broken, missing, ambiguous, or risky.",
80
+ "Record concrete code/docs observations with file references.",
81
+ "List plausible causes or design gaps before choosing an approach.",
82
+ "Explain the selected approach and why alternatives were not chosen.",
83
+ "Describe the implementation strategy without using progress checkboxes.",
84
+ "List commands to run, expected outcomes, and residual risks.",
85
+ "Record the concrete symptom, risk, or requested change this ticket owns.",
86
+ "State what is broken, what is missing, and who or what is affected.",
87
+ "Capture the current best explanation and cite affected files, symbols, commands, or rules.",
88
+ "Record MCP tool/query quality when used:",
89
+ "Capture the selected design path and implementation direction.",
90
+ "List the smallest relevant commands/checks, expected result, and the pass/fail signal"
91
+ ];
92
+
93
+ function hasSubstantivePlanContent(text) {
94
+ const normalized = String(text || "").replace(/\s+/g, " ").trim().toLowerCase();
95
+ if (!normalized) return false;
96
+ if (normalized.includes("[add ") || normalized.includes("[fill") || normalized.includes("todo") || normalized.includes("tbd")) return false;
97
+ return !PLAN_SCAFFOLD_PHRASES.some(phrase => normalized.includes(phrase.toLowerCase()));
98
+ }
99
+
100
+ function looksLikeInvestigationTicket(summary, title, topic) {
101
+ const haystack = [summary, title, topic].filter(Boolean).join(" ").toLowerCase();
102
+ if (!haystack) return false;
103
+ return [
104
+ "audit",
105
+ "review",
106
+ "investigate",
107
+ "investigation",
108
+ "root cause",
109
+ "root-cause",
110
+ "why ",
111
+ " why",
112
+ "issue",
113
+ "regression",
114
+ "bug",
115
+ "failure",
116
+ "broken",
117
+ "unexpected",
118
+ "does not",
119
+ "doesn't",
120
+ "not working"
121
+ ].some(token => haystack.includes(token));
122
+ }
123
+
124
+ const DURABLE_EVIDENCE_SECTION_NAMES = [
125
+ "Source Observations",
126
+ "Audit Evidence",
127
+ "Audit Findings",
128
+ "Findings",
129
+ "Verification Outcome"
130
+ ];
131
+
132
+ const EVIDENCE_SCAFFOLD_PHRASES = [
133
+ "Record confirmed local, RAG, code, command, or document evidence.",
134
+ "Record the concrete symptom, risk, or requested change this ticket owns.",
135
+ "Root causes are documented and explained.",
136
+ "Report is delivered to the user."
137
+ ];
138
+
139
+ const ANALYSIS_DESIGN_SECTION_REQUIREMENTS = [
140
+ {
141
+ section: "Problem Analysis",
142
+ reason: "problem_analysis_missing",
143
+ scaffolds: [
144
+ "For investigation, regression, quality, or root-cause tickets, record the current analysis here before asking the user for clarification.",
145
+ "Chat should point back to this ticket after the analysis is recorded."
146
+ ]
147
+ },
148
+ {
149
+ section: "Improvement Direction",
150
+ reason: "improvement_direction_missing",
151
+ scaffolds: [
152
+ "Record the proposed fix direction or follow-up design path."
153
+ ]
154
+ }
155
+ ];
156
+
157
+ const INVESTIGATION_ANALYSIS_SECTION_REQUIREMENTS = [
158
+ {
159
+ section: "Source Observations",
160
+ reason: "source_observations_missing",
161
+ scaffolds: [
162
+ "Record confirmed local, RAG, code, command, or document evidence."
163
+ ]
164
+ },
165
+ {
166
+ section: "Cause Hypotheses",
167
+ reason: "cause_hypotheses_missing",
168
+ scaffolds: [
169
+ "Record the current best explanation and competing plausible causes."
170
+ ]
171
+ }
172
+ ];
173
+
174
+ const CLAIM_STOP_WORDS = new Set([
175
+ "the",
176
+ "and",
177
+ "or",
178
+ "is",
179
+ "are",
180
+ "was",
181
+ "were",
182
+ "be",
183
+ "this",
184
+ "that",
185
+ "with",
186
+ "for",
187
+ "from",
188
+ "in",
189
+ "on",
190
+ "of",
191
+ "to",
192
+ "it",
193
+ "ticket",
194
+ "issue",
195
+ "problem",
196
+ "analysis",
197
+ "failed",
198
+ "failure",
199
+ "record",
200
+ "recorded",
201
+ "claim",
202
+ "claiming",
203
+ "result",
204
+ "resulted",
205
+ "caused",
206
+ "causing",
207
+ "this",
208
+ "그",
209
+ "이",
210
+ "이슈",
211
+ "문제",
212
+ "실패",
213
+ "원인",
214
+ "분석",
215
+ "기록",
216
+ "티켓"
217
+ ]);
218
+
219
+ function tokenizeClaimText(text) {
220
+ const raw = String(text || "").toLowerCase();
221
+ const tokens = raw.match(/[0-9a-z가-힣_.-]+/g) || [];
222
+ const filtered = tokens
223
+ .filter(t => t.length > 2)
224
+ .filter(t => !CLAIM_STOP_WORDS.has(t));
225
+ return [...new Set(filtered)];
226
+ }
227
+
228
+ function collectClaimTargetSections(content) {
229
+ const targetSections = ["Problem Analysis", "Source Observations", "Cause Hypotheses", "Improvement Direction"];
230
+ return targetSections.map(name => extractMarkdownSection(content, name)).join(" ");
231
+ }
232
+
233
+ function buildClaimCoverageSummary(claimTerms, sectionText) {
234
+ const haystack = String(sectionText || "").toLowerCase();
235
+ const normalized = haystack.replace(/\s+/g, " ");
236
+ const matched = claimTerms.filter(term => normalized.includes(term));
237
+ const missRate = claimTerms.length === 0 ? 0 : 1 - matched.length / claimTerms.length;
238
+ return {
239
+ matched,
240
+ missRate,
241
+ total: claimTerms.length
242
+ };
243
+ }
244
+
245
+ function extractMarkdownSection(content, heading) {
246
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
247
+ const match = String(content || "").match(new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im"));
248
+ return match?.[1] || "";
249
+ }
250
+
251
+ function hasSubstantiveSectionContent(text, scaffolds = []) {
252
+ const src = String(text || "").trim();
253
+ if (!src || hasPlaceholderTokens(src)) return false;
254
+ const normalized = src.replace(/\s+/g, " ");
255
+ return !scaffolds.some(phrase => normalized.includes(phrase));
256
+ }
257
+
258
+ function getAnalysisDesignIncompleteReasons(meta, content) {
259
+ const reasons = [];
260
+ for (const requirement of ANALYSIS_DESIGN_SECTION_REQUIREMENTS) {
261
+ if (!hasSubstantiveSectionContent(extractMarkdownSection(content, requirement.section), requirement.scaffolds)) {
262
+ reasons.push(requirement.reason);
263
+ }
264
+ }
265
+
266
+ if (shouldRequireMainTicketEvidence(meta, content)) {
267
+ for (const requirement of INVESTIGATION_ANALYSIS_SECTION_REQUIREMENTS) {
268
+ if (!hasSubstantiveSectionContent(extractMarkdownSection(content, requirement.section), requirement.scaffolds)) {
269
+ reasons.push(requirement.reason);
270
+ }
271
+ }
272
+ }
273
+
274
+ return reasons;
275
+ }
276
+
277
+ function hasConcreteEvidenceSignals(text) {
278
+ const src = String(text || "").trim();
279
+ if (!src || hasPlaceholderTokens(src)) return false;
280
+ const normalized = src.replace(/\s+/g, " ");
281
+ if (EVIDENCE_SCAFFOLD_PHRASES.some(phrase => normalized.includes(phrase))) return false;
282
+ return [
283
+ /`[^`]+\.(?:[cm]?[jt]s|tsx?|jsx?|mjs|cjs|json|ya?ml|ejs|md|rs|py|java|cs|cpp|hpp|h|ex|exs|go|kt|swift|rb|php|sh)`/i,
284
+ /\b[\w./-]+\.(?:[cm]?[jt]s|tsx?|jsx?|mjs|cjs|json|ya?ml|ejs|md|rs|py|java|cs|cpp|hpp|h|ex|exs|go|kt|swift|rb|php|sh)(?::\d+)?\b/i,
285
+ /\b(?:rg|node|npm|npx|jest|pytest|cargo|dotnet|mvn|gradle|git)\b/i,
286
+ /\b(?:function|class|method|symbol|line|라인|파일|명령|검증|테스트|결과)\b/i,
287
+ /`[^`]+`/
288
+ ].some(pattern => pattern.test(src));
289
+ }
290
+
291
+ function hasMainTicketDurableEvidence(content) {
292
+ return DURABLE_EVIDENCE_SECTION_NAMES.some(sectionName => hasConcreteEvidenceSignals(extractMarkdownSection(content, sectionName)));
293
+ }
294
+
295
+ function shouldRequireMainTicketEvidence(meta, content) {
296
+ return looksLikeInvestigationTicket(meta.summary, meta.title, meta.id || meta.topic);
297
+ }
298
+
299
+ function getMainTicketEvidenceReasons(meta, content) {
300
+ if (shouldRequireMainTicketEvidence(meta, content) && !hasMainTicketDurableEvidence(content)) {
301
+ return ["audit_evidence_missing"];
302
+ }
303
+ return [];
304
+ }
305
+
306
+ function validateClaimAgainstTicketContent(meta, content, claim) {
307
+ const reasons = [];
308
+ const claimTerms = tokenizeClaimText(claim);
309
+ if (claimTerms.length === 0) {
310
+ reasons.push("claim_terms_missing");
311
+ return reasons;
312
+ }
313
+
314
+ const coverageText = collectClaimTargetSections(content);
315
+ const coverage = buildClaimCoverageSummary(claimTerms, coverageText);
316
+ const matchingRate = coverage.matched.length / Math.max(coverage.total, 1);
317
+ if (matchingRate < 0.33) {
318
+ reasons.push(`claim_coverage_missing:${coverage.matched.length}/${coverage.total}`);
319
+ }
320
+
321
+ if (!meta.summary || hasPlaceholderTokens(meta.summary)) {
322
+ reasons.push("claim_ticket_summary_missing");
323
+ }
324
+
325
+ const phase1Missing = getAnalysisDesignIncompleteReasons(meta, content);
326
+ if (phase1Missing.length > 0) {
327
+ reasons.push("claim_ticket_incomplete_record");
328
+ }
329
+
330
+ return reasons;
331
+ }
332
+
333
+ function getClaimEvidenceResult(target, meta, content, claim) {
334
+ const reasons = validateClaimAgainstTicketContent(meta, content, claim);
335
+ const claimTerms = tokenizeClaimText(claim);
336
+ const coverage = buildClaimCoverageSummary(claimTerms, collectClaimTargetSections(content));
337
+ return {
338
+ ok: reasons.length === 0,
339
+ reasons,
340
+ ticket: target.topic,
341
+ path: target.path,
342
+ claim,
343
+ claimTerms: coverage.total,
344
+ coveredTerms: coverage.matched.length,
345
+ matchedTerms: coverage.matched,
346
+ missRate: Number((coverage.missRate * 100).toFixed(1)),
347
+ sections: {
348
+ problemAnalysis: extractMarkdownSection(content, "Problem Analysis").trim(),
349
+ sourceObservations: extractMarkdownSection(content, "Source Observations").trim(),
350
+ causeHypotheses: extractMarkdownSection(content, "Cause Hypotheses").trim(),
351
+ improvementDirection: extractMarkdownSection(content, "Improvement Direction").trim()
352
+ }
353
+ };
354
+ }
355
+
356
+ const IMPLEMENTATION_CLAIM_PATTERNS = [
357
+ /\b(?:fix|fixed|implement|implemented|apply|applied|change(?:d)?|patch(?:ed)?|resolved?)\b/i,
358
+ /(수정|구현|적용|변경|패치|해결)(?:했|됨|완료|함)?/i
359
+ ];
360
+
361
+ function claimImpliesCodeChange(text) {
362
+ const src = String(text || "").trim();
363
+ if (!src) return false;
364
+ return IMPLEMENTATION_CLAIM_PATTERNS.some(pattern => pattern.test(src));
365
+ }
366
+
367
+ function isTicketOwnedPath(relPath) {
368
+ const normalized = toPosixPath(String(relPath || ""));
369
+ return normalized.startsWith(`${AGENT_ROOT_DIR}/tickets/`) || normalized.startsWith(`${AGENT_ROOT_DIR}/docs/`);
370
+ }
371
+
372
+ function collectChangedSourceFiles(cwd, changedFilesOverride = null) {
373
+ const changed = Array.isArray(changedFilesOverride) ? changedFilesOverride : collectChangedFiles(cwd);
374
+ return changed.filter(relPath => !isTicketOwnedPath(relPath));
375
+ }
376
+
377
+ function extractLikelyAffectedFiles(text) {
378
+ 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) || [];
379
+ return [...new Set(matches.map(match => toPosixPath(match.trim()).replace(/^\.\//, "")))];
380
+ }
381
+
382
+ export function getImplementationClaimGuardResult(cwd, { claim = "", content = "", changedFiles = null } = {}) {
383
+ const changedSourceFiles = collectChangedSourceFiles(cwd, changedFiles);
384
+ const effectiveClaim = String(claim || "").trim();
385
+ const verificationOutcome = extractMarkdownSection(content, "Verification Outcome");
386
+ const candidateText = [effectiveClaim, verificationOutcome].filter(Boolean).join("\n");
387
+
388
+ if (!claimImpliesCodeChange(candidateText)) {
389
+ return { ok: true, changedFiles: changedSourceFiles, affectedFiles: [] };
390
+ }
391
+
392
+ const affectedFiles = extractLikelyAffectedFiles(candidateText);
393
+ const normalizedChanged = changedSourceFiles.map(file => toPosixPath(String(file || "")).replace(/^\.\//, ""));
394
+ const overlap = affectedFiles.filter(file => normalizedChanged.includes(file));
395
+ const reasons = [];
396
+
397
+ if (normalizedChanged.length === 0) {
398
+ reasons.push("implementation_changed_files_missing");
399
+ }
400
+ if (affectedFiles.length > 0 && overlap.length === 0) {
401
+ reasons.push("implementation_affected_files_not_changed");
402
+ }
403
+
404
+ return {
405
+ ok: reasons.length === 0,
406
+ reasons,
407
+ changedFiles: changedSourceFiles,
408
+ affectedFiles,
409
+ overlap
410
+ };
411
+ }
412
+
413
+ function isCompactTicketOutput(opts = {}) {
414
+ return Boolean(opts.compact || opts.nonInteractive);
415
+ }
416
+
417
+ function getHandoffSummary(out) {
418
+ const next = out.nextTicket ? `${out.nextTicket.id}:${out.nextTicket.status}` : "none";
419
+ const blockers = out.reasons?.length ? out.reasons.join(",") : "none";
420
+ return `${out.current.id} | phase=${out.current.phase} | status=${out.current.status} | next=${next} | blockers=${blockers}`;
421
+ }
422
+
423
+ function summarizeForSentence(summary) {
424
+ const clean = String(summary || "").replace(/\s+/g, " ").trim();
425
+ return clean.length > 180 ? `${clean.slice(0, 177)}...` : clean;
426
+ }
427
+
428
+ function buildApcDraft(summary) {
429
+ const s = summarizeForSentence(summary);
430
+ return {
431
+ boundaryEditable: `- Editable modules: ticket target modules directly related to \"${s}\"`,
432
+ boundaryForbidden: "- Forbidden modules: generated artifacts, unrelated shared infrastructure, external module roots",
433
+ boundaryRule: "- Rule citation: PROJECT_RULE.md + core-rules/AGENTS.md",
434
+ contractInput: `- Input: existing code/context required to implement \"${s}\"`,
435
+ contractOutput: `- Output: minimal implementation and tests that satisfy \"${s}\"`,
436
+ contractSideEffects: "- Side effects: ticket updates, scoped code changes only",
437
+ patchPlan: [
438
+ "- Compact planning lives in this ticket.",
439
+ "- Use CLI-linked subissues for related work instead of expanding this ticket.",
440
+ "- Ticket content owns scope/APC/evidence; core rules own screen-output policy."
441
+ ].join("\n")
442
+ };
443
+ }
444
+
445
+ function evaluateApcCompleteness(content) {
446
+ const reasons = [];
447
+ const apcMatch = String(content || "").match(/## Agent Permission Contract[\s\S]*?(?=\n## |$)/i);
448
+ if (!apcMatch) {
449
+ reasons.push("missing_apc_block");
450
+ return reasons;
451
+ }
452
+ const apcText = apcMatch[0];
453
+ const boundaryMatch = apcText.match(/### \[BOUNDARY\]([\s\S]*?)(?=\n### |$)/i);
454
+ const contractMatch = apcText.match(/### \[CONTRACT\]([\s\S]*?)(?=\n### |$)/i);
455
+ const planMatch = apcText.match(/### \[PATCH PLAN\]([\s\S]*?)(?=\n### |$)/i);
456
+
457
+ if (!boundaryMatch?.[1] || hasPlaceholderTokens(boundaryMatch[1])) reasons.push("apc_boundary_incomplete");
458
+ if (!contractMatch?.[1] || hasPlaceholderTokens(contractMatch[1])) reasons.push("apc_contract_incomplete");
459
+ if (!planMatch?.[1] || hasPlaceholderTokens(planMatch[1])) reasons.push("apc_patch_plan_incomplete");
460
+ return reasons;
461
+ }
462
+
463
+ function lintTicketLifecycleMarkdown(cwd, targets, context) {
464
+ const uniqueTargets = Array.from(new Set((targets || []).filter(Boolean)));
465
+ if (uniqueTargets.length === 0) return { errors: [], targets: [] };
466
+
467
+ const result = lintMarkdownPaths(uniqueTargets, cwd);
468
+ if (result.errors.length > 0) {
469
+ const details = result.errors.map(err => `- ${err}`).join("\n");
470
+ throw new Error(`[VALIDATION FAILED] ${context}: markdown lint failed\n${details}`);
471
+ }
472
+ return result;
473
+ }
474
+
475
+ function looksLikeTicketMarkdownPath(value) {
476
+ const raw = String(value || "");
477
+ return /\.md$/i.test(raw) && /[/\\]/.test(raw);
478
+ }
479
+
480
+ function findTicketRepoRootFromPath(absPath) {
481
+ let dir = dirname(absPath);
482
+ while (true) {
483
+ if (basename(dir) === TICKET_SUBDIR && basename(dirname(dir)) === AGENT_ROOT_DIR) {
484
+ return dirname(dirname(dir));
485
+ }
486
+ const parent = dirname(dir);
487
+ if (parent === dir) return null;
488
+ dir = parent;
489
+ }
490
+ }
491
+
492
+ function applyTicketPathContext(opts = {}) {
493
+ const rawTicketPath = opts.ticketPath || (looksLikeTicketMarkdownPath(opts.topic) ? opts.topic : "");
494
+ if (!rawTicketPath) return opts;
495
+
496
+ const absPath = resolve(opts.cwd, rawTicketPath);
497
+ if (!existsSync(absPath)) {
498
+ throw new Error(`ticket path not found: ${rawTicketPath}`);
499
+ }
500
+
501
+ const ticketRepoRoot = findTicketRepoRootFromPath(absPath);
502
+ if (!ticketRepoRoot) {
503
+ throw new Error(`ticket path is not inside ${AGENT_ROOT_DIR}/${TICKET_SUBDIR}: ${rawTicketPath}`);
504
+ }
505
+
506
+ const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
507
+ const ticketTopic = meta.topic || meta.id || basename(absPath).replace(/\.md$/i, "");
508
+ if (!ticketTopic) {
509
+ throw new Error(`ticket path has no id/topic frontmatter: ${rawTicketPath}`);
510
+ }
511
+
512
+ opts.cwd = ticketRepoRoot;
513
+ opts.topic = ticketTopic;
514
+ opts.ticketPath = absPath;
515
+ return opts;
516
+ }
517
+
518
+ function ticketIndexPathForRoot(root) {
519
+ return join(root, AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_INDEX_FILENAME);
520
+ }
521
+
522
+ function collectNearbyTicketRoots(cwd) {
523
+ const roots = new Set();
524
+ let dir = resolve(cwd);
525
+
526
+ while (true) {
527
+ if (existsSync(ticketIndexPathForRoot(dir))) roots.add(dir);
528
+
529
+ try {
530
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
531
+ if (!item.isDirectory() || item.name.startsWith(".")) continue;
532
+ const childRoot = join(dir, item.name);
533
+ if (existsSync(ticketIndexPathForRoot(childRoot))) roots.add(childRoot);
534
+ }
535
+ } catch {
536
+ // Some ancestors may not be readable; skip them and keep climbing.
537
+ }
538
+
539
+ const parent = dirname(dir);
540
+ if (parent === dir || basename(dir) === "home") break;
541
+ dir = parent;
542
+ }
543
+
544
+ return [...roots];
545
+ }
546
+
547
+ function matchingTicketEntriesForTopic(indexJson, topic) {
548
+ const key = String(topic || "").toLowerCase();
549
+ if (!key) return [];
550
+ const rows = indexJson.entries || [];
551
+ const exact = rows.filter(entry =>
552
+ String(entry.topic || "").toLowerCase() === key ||
553
+ String(entry.id || "").toLowerCase() === key
554
+ );
555
+ if (exact.length > 0) return exact;
556
+ return rows.filter(entry =>
557
+ String(entry.topic || "").toLowerCase().includes(key) ||
558
+ String(entry.id || "").toLowerCase().includes(key)
559
+ );
560
+ }
561
+
562
+ function applyTicketTopicContext(opts = {}) {
563
+ if (!opts.topic || opts.latest || looksLikeTicketMarkdownPath(opts.topic)) return opts;
564
+
565
+ const currentIndexPath = ticketIndexPathForRoot(opts.cwd);
566
+ if (existsSync(currentIndexPath)) {
567
+ const currentIndex = readTicketIndexJson(opts.cwd);
568
+ if (pickTicketEntry(opts, currentIndex)) return opts;
569
+ }
570
+
571
+ const matches = [];
572
+ for (const root of collectNearbyTicketRoots(opts.cwd)) {
573
+ if (resolve(root) === resolve(opts.cwd)) continue;
574
+ let indexJson;
575
+ try {
576
+ indexJson = JSON.parse(readFileSync(ticketIndexPathForRoot(root), "utf8"));
577
+ } catch {
578
+ continue;
579
+ }
580
+ for (const entry of matchingTicketEntriesForTopic(indexJson, opts.topic)) {
581
+ matches.push({ root, entry });
582
+ }
583
+ }
584
+
585
+ if (matches.length === 0) return opts;
586
+ if (matches.length > 1) {
587
+ const details = matches
588
+ .slice(0, 10)
589
+ .map(match => `- ${match.entry.id || match.entry.topic} @ ${match.root}`)
590
+ .join("\n");
591
+ throw new Error(`Ambiguous ticket topic "${opts.topic}" found in multiple repositories:\n${details}`);
592
+ }
593
+
594
+ opts.cwd = matches[0].root;
595
+ opts.topic = matches[0].entry.topic || matches[0].entry.id;
596
+ return opts;
597
+ }
598
+
599
+ function applyTicketContext(opts = {}) {
600
+ applyTicketPathContext(opts);
601
+ applyTicketTopicContext(opts);
602
+ return opts;
603
+ }
604
+
605
+ function collectTicketLifecycleMarkdownTargets(cwd, ticketAbsPath, extraTargets = []) {
606
+ const targets = [];
607
+ if (ticketAbsPath) targets.push(ticketAbsPath);
608
+
609
+ for (const relPath of collectChangedMarkdownFiles(cwd)) {
610
+ targets.push(join(cwd, relPath));
611
+ }
612
+
613
+ for (const target of extraTargets || []) {
614
+ if (!target) continue;
615
+ targets.push(target);
616
+ }
617
+
618
+ return Array.from(new Set(targets));
619
+ }
620
+
621
+ function restoreTicketIndexSnapshot(cwd, snapshot, opts = {}) {
622
+ if (opts.dryRun) return;
623
+ writeTicketIndexJson(cwd, snapshot, opts);
624
+ }
625
+
626
+ function rollbackTicketLifecycleArtifacts(cwd, previousIndex, previousBody, absPath, opts = {}) {
627
+ if (opts.dryRun) return;
628
+ if (previousBody !== undefined && absPath) {
629
+ writeFileSync(absPath, previousBody, "utf8");
630
+ }
631
+ if (previousIndex) {
632
+ restoreTicketIndexSnapshot(cwd, previousIndex, opts);
633
+ }
634
+ }
635
+
636
+ function getPhase1IncompleteReasonsFromBody(body) {
637
+ const { meta, content } = parseFrontMatter(body);
638
+ const phase = Number(meta.phase || 1);
639
+ if (phase !== 1) return [];
640
+
641
+ const reasons = [];
642
+ if (!meta.summary || hasPlaceholderTokens(meta.summary)) reasons.push("summary_missing_or_placeholder");
643
+ if (!/## Compact Plan/i.test(content) || !hasSubstantivePlanContent(content.split(/## Compact Plan/i)[1] || "")) {
644
+ reasons.push("compact_plan_placeholder_or_incomplete");
645
+ }
646
+
647
+ reasons.push(...evaluateApcCompleteness(content));
648
+ reasons.push(...getAnalysisDesignIncompleteReasons(meta, content));
649
+ reasons.push(...getMainTicketEvidenceReasons(meta, content));
650
+ return [...new Set(reasons)];
651
+ }
652
+
653
+ function getPhase1IncompleteReasons(cwd, absPath) {
654
+ if (!existsSync(absPath)) return ["ticket_file_missing"];
655
+ return getPhase1IncompleteReasonsFromBody(readFileSync(absPath, "utf8"));
656
+ }
657
+
658
+ function buildStrictCreateFailureMessage(reasons) {
659
+ const uniqueReasons = [...new Set(reasons || [])];
660
+ const lines = [
661
+ "[VALIDATION FAILED] ticket create strict mode rejected incomplete Phase 1.",
662
+ `Missing: ${uniqueReasons.join(", ")}`,
663
+ "",
664
+ "Required one-pass inputs:",
665
+ " --summary \"<concrete request summary>\"",
666
+ " --plan-body \"<filled Phase 1 markdown containing every required section below>\"",
667
+ "",
668
+ "Required --plan-body sections:",
669
+ " # <title>",
670
+ " ## Agent Permission Contract (APC)",
671
+ " ### [BOUNDARY]",
672
+ " ### [CONTRACT]",
673
+ " ### [PATCH PLAN]",
674
+ " ## Compact Plan",
675
+ " ## Problem Analysis",
676
+ " ## Source Observations",
677
+ " ## Cause Hypotheses",
678
+ " ## Improvement Direction",
679
+ " ## Audit Evidence",
680
+ "",
681
+ "Copy/paste command shape:",
682
+ " npx deuk-agent-rule ticket create --topic <topic> --summary \"<concrete summary>\" --plan-body \"$(cat <<'EOF'",
683
+ " # <title>",
684
+ " <filled Phase 1 markdown with all sections listed above>",
685
+ " EOF",
686
+ " )\" --non-interactive",
687
+ "",
688
+ "Manual fallback is forbidden: do not write .deuk-agent/tickets/**/*.md directly after this failure."
689
+ ];
690
+
691
+ if (uniqueReasons.includes("summary_missing_or_placeholder")) {
692
+ lines.push("Summary fix: replace placeholder/TBD wording with a concrete --summary value.");
693
+ }
694
+ if (uniqueReasons.includes("missing_apc_block") || uniqueReasons.some(reason => reason.startsWith("apc_"))) {
695
+ lines.push("APC fix: include ## Agent Permission Contract (APC) with [BOUNDARY], [CONTRACT], and [PATCH PLAN].");
696
+ }
697
+ if (uniqueReasons.includes("compact_plan_placeholder_or_incomplete")) {
698
+ lines.push("Compact Plan fix: replace scaffold text with concrete finding, direction, and verification lines.");
699
+ }
700
+ if (uniqueReasons.some(reason => [
701
+ "problem_analysis_missing",
702
+ "source_observations_missing",
703
+ "cause_hypotheses_missing",
704
+ "improvement_direction_missing",
705
+ "audit_evidence_missing"
706
+ ].includes(reason))) {
707
+ lines.push("Investigation fix: record concrete evidence with file/command references before creating the ticket.");
708
+ }
709
+
710
+ return lines.join("\n");
711
+ }
712
+
713
+ function isExecutionTicketStatus(status) {
714
+ return OPEN_TICKET_STATUSES.has(String(status || "open").toLowerCase());
715
+ }
716
+
717
+ function getTicketLifecycleProvenanceReasons(entry, meta = {}) {
718
+ const reasons = [];
719
+ if (!entry || String(entry.status || "").toLowerCase() === "archived") return reasons;
720
+ if (!isExecutionTicketStatus(entry.status || meta.status || "open")) return reasons;
721
+
722
+ const lifecycleSource = String(meta.lifecycleSource || meta.ticketLifecycleSource || "").trim();
723
+ if (lifecycleSource !== "ticket-create") {
724
+ reasons.push("manual_ticket_lifecycle_provenance_missing");
725
+ }
726
+ return reasons;
727
+ }
728
+
729
+ function assertTicketLifecycleProvenance(entry, meta = {}) {
730
+ const reasons = getTicketLifecycleProvenanceReasons(entry, meta);
731
+ if (reasons.length === 0) return;
732
+ throw new Error([
733
+ `[VALIDATION FAILED] Ticket ${entry?.id || entry?.topic || "unknown"} cannot be used as an execution ticket: ${reasons.join(", ")}.`,
734
+ "This ticket file does not carry CLI creation provenance.",
735
+ "Do not create or repair tickets by writing .deuk-agent/tickets/**/*.md directly.",
736
+ "Use: npx deuk-agent-rule ticket create --topic <topic> --summary <summary> --plan-body \"<filled phase 1 markdown>\" --non-interactive"
737
+ ].join("\n"));
738
+ }
739
+
740
+ function updatePreviousTicketRef(cwd, prevTicketEntry, ticketId) {
741
+ if (!prevTicketEntry) return;
742
+ const prevAbsPath = join(cwd, prevTicketEntry.path);
743
+ if (!existsSync(prevAbsPath)) return;
744
+
745
+ let prevContent = readFileSync(prevAbsPath, "utf8");
746
+ prevContent = prevContent.replace(/^---\n([\s\S]*?)\n---/, (match, fm) => {
747
+ if (!fm.includes('nextTicket:')) {
748
+ return `---\n${fm.trim()}\nnextTicket: ${ticketId}\n---`;
749
+ }
750
+ return match;
751
+ });
752
+ writeFileSync(prevAbsPath, prevContent, "utf8");
753
+ return prevTicketEntry.id;
754
+ }
755
+
756
+ function archivePartitionForEntry(entry, now = new Date()) {
757
+ const storedYearMonth = String(entry?.archiveYearMonth || "");
758
+ const storedDay = String(entry?.archiveDay || "");
759
+ if (/^\d{4}-\d{2}$/.test(storedYearMonth) && /^\d{2}$/.test(storedDay)) {
760
+ return { yearMonth: storedYearMonth, day: storedDay };
761
+ }
762
+
763
+ const source = String(entry?.createdAt || "");
764
+ const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
765
+ if (match) return { yearMonth: `${match[1]}-${match[2]}`, day: match[3] };
766
+
767
+ const iso = now.toISOString();
768
+ return { yearMonth: iso.slice(0, 7), day: iso.slice(8, 10) };
769
+ }
770
+
771
+ function getArchiveDestination(ticketDir, entry, fileName) {
772
+ const partition = archivePartitionForEntry(entry);
773
+ const archiveDir = join(ticketDir, "archive", entry.group || "sub", partition.yearMonth, partition.day);
774
+ return {
775
+ archiveDir,
776
+ archiveYearMonth: partition.yearMonth,
777
+ archiveDay: partition.day,
778
+ newAbsPath: join(archiveDir, fileName)
779
+ };
780
+ }
781
+
782
+ function archiveStorageFromPath(ticketDir, absPath, entry) {
783
+ const parts = toPosixPath(relative(ticketDir, absPath)).split("/");
784
+ const archiveIdx = parts.indexOf("archive");
785
+ if (archiveIdx < 0) return archivePartitionForEntry(entry);
786
+ return {
787
+ archiveYearMonth: parts[archiveIdx + 2] || archivePartitionForEntry(entry).yearMonth,
788
+ archiveDay: parts[archiveIdx + 3] || archivePartitionForEntry(entry).day
789
+ };
790
+ }
791
+
792
+ function findExistingArchivedTicketPath(ticketDir, entry, fileName) {
793
+ const expected = getArchiveDestination(ticketDir, entry, fileName).newAbsPath;
794
+ if (existsSync(expected)) return expected;
795
+
796
+ const archiveRoot = join(ticketDir, "archive", entry.group || "sub");
797
+ if (!existsSync(archiveRoot)) return null;
798
+
799
+ const stack = [archiveRoot];
800
+ while (stack.length > 0) {
801
+ const dir = stack.pop();
802
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
803
+ const abs = join(dir, item.name);
804
+ if (item.isDirectory()) {
805
+ stack.push(abs);
806
+ } else if (item.isFile() && item.name === fileName) {
807
+ return abs;
808
+ }
809
+ }
810
+ }
811
+ return null;
812
+ }
813
+
814
+ function isOpenTicketEntry(entry) {
815
+ return OPEN_TICKET_STATUSES.has(String(entry?.status || "open"));
816
+ }
817
+
818
+ function isAutoArchivableDoneEntry(entry) {
819
+ return AUTO_ARCHIVE_DONE_STATUSES.has(String(entry?.status || "").toLowerCase());
820
+ }
821
+
822
+ function latestTicketByStatus(entries, statuses) {
823
+ const statusSet = new Set(statuses);
824
+ return [...(entries || [])]
825
+ .filter(e => statusSet.has(String(e.status || "").toLowerCase()))
826
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0] || null;
827
+ }
828
+
829
+ function formatTicketChoice(entry) {
830
+ const status = String(entry.status || "open");
831
+ const createdAt = String(entry.createdAt || "-");
832
+ const title = String(entry.title || entry.topic || entry.id || "").replace(/(\n|\\n)+/g, " ").slice(0, 80);
833
+ return `${entry.id} | ${status} | ${createdAt} | ${title}`;
834
+ }
835
+
836
+ function buildUseFallbackCandidates(indexJson) {
837
+ const entries = indexJson.entries || [];
838
+ const lastClosed = latestTicketByStatus(entries, ["closed"]);
839
+ const openRows = entries
840
+ .filter(e => OPEN_TICKET_STATUSES.has(String(e.status || "open")))
841
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")));
842
+
843
+ const seen = new Set();
844
+ return [lastClosed, ...openRows]
845
+ .filter(Boolean)
846
+ .filter(entry => {
847
+ if (seen.has(entry.id)) return false;
848
+ seen.add(entry.id);
849
+ return true;
850
+ });
851
+ }
852
+
853
+ function buildUseNoMatchError(topic, candidates) {
854
+ const lines = [
855
+ `No matching ticket found for "${topic || ""}".`,
856
+ "Last closed ticket and open tickets:"
857
+ ];
858
+
859
+ if (candidates.length === 0) {
860
+ lines.push(" - none");
861
+ } else {
862
+ for (const entry of candidates.slice(0, 20)) {
863
+ lines.push(` - ${formatTicketChoice(entry)}`);
864
+ }
865
+ }
866
+
867
+ lines.push("");
868
+ lines.push("Choose one explicitly:");
869
+ lines.push(" npx deuk-agent-rule ticket use --topic <ticket-id> --non-interactive");
870
+ return lines.join("\n");
871
+ }
872
+
873
+ function oldestFirst(a, b) {
874
+ return String(a.createdAt || "").localeCompare(String(b.createdAt || ""));
875
+ }
876
+
877
+ function selectOpenLimitCandidates(indexJson) {
878
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
879
+ const overflow = openRows.length - MAX_OPEN_TICKETS;
880
+ if (overflow <= 0) return [];
881
+
882
+ const currentActiveId = indexJson.activeTicketId;
883
+ const openCandidates = openRows
884
+ .filter(e => e.status === "open" && e.id !== currentActiveId)
885
+ .sort(oldestFirst);
886
+ const activeCandidates = openRows
887
+ .filter(e => e.status === "active" && e.id !== currentActiveId)
888
+ .sort(oldestFirst);
889
+ const lastResort = openRows
890
+ .filter(e => e.id === currentActiveId)
891
+ .sort(oldestFirst);
892
+
893
+ return [...openCandidates, ...activeCandidates, ...lastResort].slice(0, overflow);
894
+ }
895
+
896
+ function buildOpenTicketLimitError(indexJson) {
897
+ const openRows = (indexJson.entries || []).filter(isOpenTicketEntry);
898
+ if (openRows.length <= MAX_OPEN_TICKETS) return null;
899
+
900
+ const candidates = selectOpenLimitCandidates(indexJson);
901
+ const lines = [
902
+ `[OPEN TICKET LIMIT] Open tickets: ${openRows.length}/${MAX_OPEN_TICKETS}.`,
903
+ "Ticket creation was cancelled so open tickets do not exceed the limit.",
904
+ "Review the active ticket list, decide what can be archived, then create the ticket again.",
905
+ "",
906
+ "Commands:",
907
+ " npx deuk-agent-rule ticket list --active --non-interactive",
908
+ " npx deuk-agent-rule ticket archive --topic <ticket-id> --non-interactive",
909
+ "",
910
+ "Oldest archive candidates:"
911
+ ];
912
+
913
+ for (const entry of candidates.slice(0, 10)) {
914
+ const title = String(entry.title || entry.topic || "").replace(/(\n|\\n)+/g, " ").slice(0, 80);
915
+ lines.push(` - ${entry.id} | ${entry.status || "open"} | ${entry.createdAt || "-"} | ${title}`);
916
+ }
917
+
918
+ return lines.join("\n");
919
+ }
920
+
921
+ function resolveArchiveReport(cwd, fileName, report) {
922
+ if (report) return resolve(cwd, report);
923
+
924
+ const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
925
+ const potentialReport = fileName.replace(/\.md$/i, "-report.md");
926
+ const potentialPath = join(reportDir, potentialReport);
927
+ return existsSync(potentialPath) ? potentialPath : null;
928
+ }
929
+
930
+ function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {}, report }) {
931
+ const absPath = join(cwd, found.path);
932
+ const fileName = found.path.split(/[/\\]/).pop();
933
+ if (!existsSync(absPath)) {
934
+ const archivedAbsPath = findExistingArchivedTicketPath(ticketDir, found, fileName);
935
+ if (archivedAbsPath) {
936
+ const storage = archiveStorageFromPath(ticketDir, archivedAbsPath, found);
937
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
938
+ if (entryIdx >= 0) {
939
+ indexJson.entries[entryIdx].fileName = fileName;
940
+ indexJson.entries[entryIdx].status = "archived";
941
+ indexJson.entries[entryIdx].archiveYearMonth = storage.archiveYearMonth;
942
+ indexJson.entries[entryIdx].archiveDay = storage.archiveDay;
943
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
944
+ }
945
+ const archivedRelativePath = toRepoRelativePath(cwd, archivedAbsPath);
946
+ if (!isCompactTicketOutput(opts)) {
947
+ console.warn("ticket archive: repaired already archived ticket " + archivedRelativePath);
948
+ }
949
+ return { id: found.id, path: archivedRelativePath, repaired: true };
950
+ }
951
+ if (String(found.status || "").toLowerCase() === "closed" && found.archiveYearMonth && found.archiveDay) {
952
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
953
+ if (entryIdx >= 0) {
954
+ indexJson.entries[entryIdx].fileName = fileName;
955
+ indexJson.entries[entryIdx].status = "archived";
956
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
957
+ }
958
+ const archivedRelativePath = computeTicketPath({
959
+ ...found,
960
+ fileName,
961
+ status: "archived"
962
+ });
963
+ if (!isCompactTicketOutput(opts)) {
964
+ console.warn("ticket archive: normalized stale closed ticket metadata " + archivedRelativePath);
965
+ }
966
+ return { id: found.id, path: archivedRelativePath, normalized: true };
967
+ }
968
+ throw new Error("ticket archive: file not found " + found.path);
969
+ }
970
+
971
+ const originalBody = readFileSync(absPath, "utf8");
972
+ const { meta: archiveMeta } = parseFrontMatter(originalBody);
973
+ const { archiveDir, archiveYearMonth, archiveDay, newAbsPath } = getArchiveDestination(ticketDir, found, fileName);
974
+ if (!opts.dryRun) mkdirSync(archiveDir, { recursive: true });
975
+
976
+ const bodyLines = originalBody.trimEnd().split(/\r?\n/);
977
+ const reportSrc = resolveArchiveReport(cwd, fileName, report);
978
+ let reportDest = null;
979
+
980
+ if (reportSrc) {
981
+ if (!existsSync(reportSrc)) {
982
+ throw new Error("ticket archive: report file not found " + report);
983
+ }
984
+ const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
985
+ if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
986
+
987
+ const reportBaseName = fileName.replace(/\.md$/i, "-report.md");
988
+ reportDest = join(reportDir, reportBaseName);
989
+ if (!opts.dryRun) copyFileSync(reportSrc, reportDest);
990
+ if (!isCompactTicketOutput(opts)) {
991
+ console.log("ticket archive: copied report to " + toFileUri(reportDest));
992
+ }
993
+
994
+ bodyLines.push("");
995
+ bodyLines.push("## 📄 Attached Report");
996
+ const relativeLink = toPosixPath(relative(dirname(newAbsPath), reportDest));
997
+ bodyLines.push(`- [View Report](${relativeLink})`);
998
+ }
999
+
1000
+ const lintTargets = collectTicketLifecycleMarkdownTargets(cwd, newAbsPath, reportDest ? [reportDest] : []);
1001
+ if (opts.dryRun) {
1002
+ if (!isCompactTicketOutput(opts)) {
1003
+ console.log("ticket archive: would move " + toRepoRelativePath(cwd, absPath) + " to " + toRepoRelativePath(cwd, newAbsPath));
1004
+ }
1005
+ return { dryRun: true };
1006
+ }
1007
+
1008
+ writeFileSync(newAbsPath, bodyLines.join("\n") + "\n", "utf8");
1009
+ rmSync(absPath);
1010
+ try {
1011
+ lintTicketLifecycleMarkdown(cwd, lintTargets, `ticket archive ${found.id}`);
1012
+ } catch (err) {
1013
+ rmSync(newAbsPath, { force: true });
1014
+ writeFileSync(absPath, originalBody, "utf8");
1015
+ if (reportDest) rmSync(reportDest, { force: true });
1016
+ throw err;
1017
+ }
1018
+
1019
+ distillKnowledge(absPath, found.id, cwd, originalBody);
1020
+ if (!isCompactTicketOutput(opts)) {
1021
+ console.log("ticket archive: moved ticket to " + toFileUri(newAbsPath));
1022
+ }
1023
+
1024
+ const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
1025
+ if (entryIdx >= 0) {
1026
+ indexJson.entries[entryIdx].fileName = fileName;
1027
+ indexJson.entries[entryIdx].status = "archived";
1028
+ indexJson.entries[entryIdx].archiveYearMonth = archiveYearMonth;
1029
+ indexJson.entries[entryIdx].archiveDay = archiveDay;
1030
+ indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
1031
+ }
1032
+
1033
+ const archivedRelativePath = toRepoRelativePath(cwd, newAbsPath);
1034
+ if (!isCompactTicketOutput(opts)) {
1035
+ console.log("ticket archive: final ticket path " + archivedRelativePath);
1036
+ }
1037
+ return { id: found.id, path: archivedRelativePath };
1038
+ }
1039
+
1040
+ function autoArchiveDoneTickets(cwd, indexJson, opts = {}) {
1041
+ const ticketDir = detectConsumerTicketDir(cwd);
1042
+ if (!ticketDir) return [];
1043
+
1044
+ const candidates = (indexJson.entries || [])
1045
+ .filter(isAutoArchivableDoneEntry)
1046
+ .sort(oldestFirst);
1047
+ const archived = [];
1048
+
1049
+ for (const candidate of candidates) {
1050
+ const result = archiveTicketEntry({ cwd, ticketDir, indexJson, found: candidate, opts, report: null });
1051
+ if (result?.id) {
1052
+ archived.push(result);
1053
+ if (!isCompactTicketOutput(opts)) {
1054
+ console.warn(`[AUTO-ARCHIVE] ${candidate.id} (${candidate.status}) archived before open-ticket limit check.`);
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ if (archived.length > 0) {
1060
+ writeTicketIndexJson(cwd, indexJson, opts);
1061
+ }
1062
+
1063
+ return archived;
1064
+ }
1065
+
1066
+ function rollbackCreatedTicket(cwd, abs, rollbackIndexJson, opts = {}) {
1067
+ if (opts.dryRun) return;
1068
+ rmSync(abs, { force: true });
1069
+ writeTicketIndexJson(cwd, rollbackIndexJson, opts);
1070
+ }
1071
+
1072
+ function buildCreateRollbackIndex(currentIndexJson, ticketId, previousIndexJson) {
1073
+ return {
1074
+ ...currentIndexJson,
1075
+ activeTicketId: previousIndexJson.activeTicketId || "",
1076
+ entries: (currentIndexJson.entries || []).filter(entry => entry.id !== ticketId)
1077
+ };
1078
+ }
1079
+
19
1080
  export async function runTicketCreate(opts) {
20
1081
  if (!opts.topic && !opts.ref) throw new Error("ticket create requires --topic or --ref");
21
-
22
1082
  const inferred = opts.ref ? inferRefTitleAndTopic(opts) : null;
23
1083
  const topic = toSlug(opts.topic || inferred?.topic || "ticket");
24
1084
  const title = opts.topic || inferred?.title || "ticket";
25
1085
  const group = toSlug(opts.group || "sub");
26
1086
 
1087
+ await ensurePhase0Validation(opts);
1088
+
27
1089
  let path, source;
28
1090
  if (opts.ref) {
29
1091
  path = resolveReferencedTicketPath(opts);
30
1092
  source = "ticket-reference";
31
1093
  } else {
32
- let tplText = "";
33
- const consumerTplPath = join(opts.cwd, AGENT_ROOT_DIR, TEMPLATE_SUBDIR, "TICKET_TEMPLATE.md");
34
- const legacyTplPath = join(opts.cwd, ".deuk-agent-templates", "TICKET_TEMPLATE.md");
35
- const bundleTplPath = join(new URL('.', import.meta.url).pathname, "..", "bundle", "templates", "TICKET_TEMPLATE.md");
36
-
37
- if (existsSync(consumerTplPath)) tplText = readFileSync(consumerTplPath, "utf8");
38
- else if (existsSync(legacyTplPath)) tplText = readFileSync(legacyTplPath, "utf8");
39
- else if (existsSync(bundleTplPath)) tplText = readFileSync(bundleTplPath, "utf8");
40
- else throw new Error("ticket create: Template not found. Please run 'npx deuk-agent-rule init' to deploy templates.");
41
-
42
- let planTplText = "";
43
- const consumerPlanTplPath = join(opts.cwd, AGENT_ROOT_DIR, TEMPLATE_SUBDIR, "PLAN_TEMPLATE.md");
44
- const bundlePlanTplPath = join(new URL('.', import.meta.url).pathname, "..", "bundle", "templates", "PLAN_TEMPLATE.md");
45
- if (existsSync(consumerPlanTplPath)) planTplText = readFileSync(consumerPlanTplPath, "utf8");
46
- else if (existsSync(bundlePlanTplPath)) planTplText = readFileSync(bundlePlanTplPath, "utf8");
47
-
48
1094
  // Find nearest or create in CWD if missing
49
1095
  const ticketDir = detectConsumerTicketDir(opts.cwd, { createIfMissing: true });
1096
+
1097
+ let parsedPlan = null;
1098
+ let finalTitle = title;
1099
+ let finalTopic = topic;
50
1100
 
1101
+ if (typeof opts.planBody === "string" && opts.planBody.trim()) {
1102
+ parsedPlan = parsePlan("inline-plan-body.md", opts.planBody);
1103
+
1104
+ finalTitle = opts.topic || parsedPlan.title || title;
1105
+ finalTopic = toSlug(finalTitle);
1106
+ }
1107
+
51
1108
  const indexJson = readTicketIndexJson(opts.cwd);
52
- const ticketId = generateTicketId(topic, indexJson.entries);
1109
+
1110
+ // Smart close: check previous active ticket's completion state before deciding
1111
+ const activeId = indexJson.activeTicketId;
1112
+ if (activeId) {
1113
+ const activeEntry = indexJson.entries.find(e => e.id === activeId && (e.status === "open" || e.status === "active"));
1114
+ if (activeEntry) {
1115
+ const absPath = join(opts.cwd, activeEntry.path);
1116
+ let shouldClose = false;
1117
+ let reason = "";
1118
+
1119
+ if (existsSync(absPath)) {
1120
+ try {
1121
+ const body = readFileSync(absPath, "utf8");
1122
+ const { meta, content } = parseFrontMatter(body);
1123
+
1124
+ // Count checklist items
1125
+ const checked = (content.match(/- \[x\]/gi) || []).length;
1126
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
1127
+ const total = checked + unchecked;
1128
+ const allDone = total > 0 && unchecked === 0;
1129
+ const phase = meta.phase || 1;
1130
+
1131
+ if (phase >= 3 && allDone) {
1132
+ shouldClose = true;
1133
+ reason = `phase=${phase}, tasks=${checked}/${total} done`;
1134
+ } else if (allDone && total > 0) {
1135
+ shouldClose = true;
1136
+ reason = `all tasks done (${checked}/${total}), phase=${phase}`;
1137
+ } else {
1138
+ reason = `phase=${phase}, tasks=${checked}/${total} done`;
1139
+ }
1140
+ } catch (err) {
1141
+ reason = "could not read ticket file";
1142
+ }
1143
+ }
1144
+
1145
+ if (shouldClose) {
1146
+ if (opts.dryRun) {
1147
+ if (!isCompactTicketOutput(opts)) {
1148
+ console.log(`[DRY-RUN] Would auto-close ${activeId} (${reason}).`);
1149
+ }
1150
+ } else {
1151
+ activeEntry.status = "closed";
1152
+ activeEntry.updatedAt = new Date().toISOString();
1153
+ // Sync to frontmatter
1154
+ if (existsSync(absPath)) {
1155
+ try {
1156
+ const body = readFileSync(absPath, "utf8");
1157
+ const parsed = parseFrontMatter(body);
1158
+ parsed.meta.status = "closed";
1159
+ parsed.meta.phase = 4;
1160
+ writeFileSync(absPath, stringifyFrontMatter(parsed.meta, parsed.content), "utf8");
1161
+ } catch (err) { /* skip */ }
1162
+ }
1163
+ writeTicketIndexJson(opts.cwd, indexJson, opts);
1164
+ if (!isCompactTicketOutput(opts)) {
1165
+ console.log(`[AUTO-CLOSE] ${activeId} completed (${reason}).`);
1166
+ }
1167
+ }
1168
+ } else {
1169
+ if (!isCompactTicketOutput(opts)) {
1170
+ console.warn(`[NOTICE] Switching from ${activeId} (${reason}). Ticket stays open.`);
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+
1177
+
1178
+
1179
+ const ticketId = generateTicketId(finalTopic, indexJson.entries);
53
1180
  const finalFileName = `${ticketId}.md`;
54
1181
 
55
1182
  const abs = join(ticketDir, group, finalFileName);
56
- mkdirSync(join(ticketDir, group), { recursive: true });
1183
+ if (!opts.dryRun) mkdirSync(join(ticketDir, group), { recursive: true });
57
1184
  path = toRepoRelativePath(opts.cwd, abs);
58
1185
 
59
- // Auto-Scaffold Plan Document
60
- const plansDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plans");
61
- const planFileName = `${ticketId}-plan.md`;
62
- const planAbs = join(plansDir, planFileName);
63
- const planLink = `[${planFileName}](file://${planAbs})`;
64
-
65
- const meta = {
1186
+ let prevTicketEntry = null;
1187
+ if (opts.chain) {
1188
+ prevTicketEntry = pickTicketEntry({ latest: true }, indexJson);
1189
+ }
1190
+
1191
+ const summary = (opts.summary || parsedPlan?.summary || "").trim();
1192
+ if (!summary) {
1193
+ throw new Error("[VALIDATION FAILED] 'summary' is mandatory and cannot be empty. Please provide a meaningful summary via --summary or within your plan.");
1194
+ }
1195
+
1196
+ const strictCreate = !opts.allowPlaceholder && (
1197
+ opts.requireFilled ||
1198
+ typeof opts.planBody === "string" ||
1199
+ looksLikeInvestigationTicket(summary, finalTitle, finalTopic)
1200
+ );
1201
+
1202
+ const promptText = [summary, finalTitle, parsedPlan?.body].filter(Boolean).join("\n");
1203
+ const { tplText, docsLanguage } = resolveTicketTemplate(opts.cwd, opts.docsLanguage, promptText);
1204
+
1205
+ const apcDraft = buildApcDraft(summary);
1206
+
1207
+ const rawMeta = {
66
1208
  id: ticketId,
67
- title,
68
- topic,
1209
+ title: finalTitle,
1210
+ phase: 1,
69
1211
  status: "open",
70
- submodule: opts.submodule || "",
71
- project: opts.project || "global",
72
- planLink: planLink,
73
- createdAt: new Date().toISOString(),
1212
+ lifecycleSource: "ticket-create",
1213
+ submodule: opts.submodule,
1214
+ project: opts.project === "global" ? undefined : opts.project,
1215
+ docsLanguage,
1216
+ evidence: opts.evidence,
1217
+ summary,
1218
+ priority: opts.priority || "P2",
1219
+ tags: opts.tags
1220
+ ? opts.tags.split(',').map(t => t.trim().replace(/^#/, '')).filter(Boolean)
1221
+ : [],
1222
+ createdAt: new Date().toISOString().replace('T', ' ').split('.')[0],
1223
+ prevTicket: prevTicketEntry ? prevTicketEntry.id : undefined,
74
1224
  };
75
1225
 
76
- const ejsFrontMatter = `---
77
- id: <%= meta.id %>
78
- title: "<%- meta.title.replace(/"/g, '\\"') %>"
79
- topic: <%= meta.topic %>
80
- status: open
81
- submodule: <%= meta.submodule %>
82
- project: <%= meta.project %>
83
- createdAt: <%= meta.createdAt %>
84
- ---
85
-
86
- `;
87
- const finalContent = ejs.render(ejsFrontMatter + tplText, { meta });
88
- writeFileSync(abs, finalContent, "utf8");
1226
+ const meta = Object.fromEntries(Object.entries(rawMeta).filter(([k, v]) => {
1227
+ if (k === 'summary') return v !== undefined; // summary는 필수이므로 undefined만 아니면 유지
1228
+ return v !== undefined && v !== "";
1229
+ }));
1230
+ const frontmatter = YAML.stringify(meta).trim();
1231
+
1232
+ let finalContent = "";
1233
+ if (parsedPlan) {
1234
+ finalContent = `---\n${frontmatter}\n---\n${parsedPlan.body}`;
1235
+ } else {
1236
+ finalContent = ejs.render(tplText, { meta, frontmatter, apcDraft });
1237
+ }
1238
+
1239
+ const lifecycleTargets = [abs];
1240
+ let rollbackIndexJson = indexJson;
1241
+
1242
+ if (!opts.dryRun) writeFileSync(abs, finalContent, "utf8");
89
1243
  source = "ticket-create";
90
-
91
- // Write Plan Document
92
- if (planTplText) {
93
- mkdirSync(plansDir, { recursive: true });
94
- const planContent = ejs.render(planTplText, { meta });
95
- writeFileSync(planAbs, planContent, "utf8");
96
- console.log(`Plan scaffolded: ${toRepoRelativePath(opts.cwd, planAbs)}`);
1244
+
1245
+ try {
1246
+ if (strictCreate) {
1247
+ const reasons = opts.dryRun
1248
+ ? getPhase1IncompleteReasonsFromBody(finalContent)
1249
+ : getPhase1IncompleteReasons(opts.cwd, abs);
1250
+ if (reasons.length > 0) {
1251
+ throw new Error(buildStrictCreateFailureMessage(reasons));
1252
+ }
1253
+ }
1254
+
1255
+ if (!opts.dryRun) {
1256
+ lintTicketLifecycleMarkdown(opts.cwd, lifecycleTargets, `ticket create ${ticketId}`);
1257
+ }
1258
+
1259
+ if (opts.dryRun) {
1260
+ const simulatedIndexJson = {
1261
+ ...indexJson,
1262
+ entries: [
1263
+ ...(indexJson.entries || []),
1264
+ {
1265
+ id: ticketId,
1266
+ title,
1267
+ topic,
1268
+ group,
1269
+ project: opts.project || "global",
1270
+ createdAt: new Date().toISOString(),
1271
+ path,
1272
+ source,
1273
+ status: "open"
1274
+ }
1275
+ ]
1276
+ };
1277
+ const limitError = buildOpenTicketLimitError(simulatedIndexJson);
1278
+ if (limitError) {
1279
+ throw new Error(limitError);
1280
+ }
1281
+ }
1282
+
1283
+ appendTicketEntry(opts.cwd, {
1284
+ id: ticketId,
1285
+ title, topic, group, project: opts.project || "global",
1286
+ createdAt: new Date().toISOString(), path, source
1287
+ }, opts);
1288
+
1289
+ const limitIndexJson = readTicketIndexJson(opts.cwd);
1290
+ autoArchiveDoneTickets(opts.cwd, limitIndexJson, opts);
1291
+
1292
+ const limitError = buildOpenTicketLimitError(readTicketIndexJson(opts.cwd));
1293
+ if (limitError) {
1294
+ rollbackIndexJson = buildCreateRollbackIndex(readTicketIndexJson(opts.cwd), ticketId, indexJson);
1295
+ throw new Error(limitError);
1296
+ }
1297
+ } catch (err) {
1298
+ if (!opts.dryRun) {
1299
+ rollbackCreatedTicket(opts.cwd, abs, rollbackIndexJson, opts);
1300
+ }
1301
+ throw err;
1302
+ }
1303
+
1304
+ if (!opts.dryRun) {
1305
+ const linkedPrev = updatePreviousTicketRef(opts.cwd, prevTicketEntry, ticketId);
1306
+ if (linkedPrev && !isCompactTicketOutput(opts)) {
1307
+ console.log(`Linked to previous ticket: ${linkedPrev}`);
1308
+ }
1309
+ }
1310
+
1311
+ console.log(`${opts.dryRun ? "Ticket would be created" : "Ticket created"}: ${toFileUri(abs)}`);
1312
+ if (!opts.dryRun) {
1313
+ appendTelemetryEvent(opts.cwd, {
1314
+ event: "ticket_created",
1315
+ action: "ticket-create",
1316
+ ticket: ticketId,
1317
+ file: path,
1318
+ phase: 1,
1319
+ status: "open"
1320
+ });
97
1321
  }
98
1322
 
99
1323
  // Remote Sync Hook
100
- const config = loadInitConfig(opts.cwd);
101
- if (config && config.remoteSync && config.pipelineUrl) {
102
- syncToPipeline(config.pipelineUrl, { action: "create", ticket: meta });
1324
+ const configSync = loadInitConfig(opts.cwd);
1325
+ if (!opts.dryRun && configSync && configSync.remoteSync && configSync.pipelineUrl) {
1326
+ syncToPipeline(configSync.pipelineUrl, { action: "create", ticket: meta });
103
1327
  }
104
-
105
- appendTicketEntry(opts.cwd, {
106
- id: ticketId,
107
- title, topic, group, project: opts.project || "global",
108
- createdAt: new Date().toISOString(), path, source
109
- }, opts);
110
1328
  }
111
1329
 
112
- syncActiveTicketId(opts.cwd);
1330
+ syncActiveTicketId(opts.cwd, opts);
113
1331
  }
114
1332
 
115
1333
  export async function runTicketList(opts) {
@@ -117,7 +1335,7 @@ export async function runTicketList(opts) {
117
1335
  if (!ticketDir) {
118
1336
  throw new Error("No ticket system found. Please run 'npx deuk-agent-rule init' first.");
119
1337
  }
120
- const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
1338
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
121
1339
  syncActiveTicketId(opts.cwd);
122
1340
  let rows = index.entries;
123
1341
 
@@ -147,10 +1365,115 @@ export async function runTicketList(opts) {
147
1365
  const safeTitle = String(e.title || e.topic || "").replace(/(\n|\\n)+/g, " ").slice(0, 50);
148
1366
  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}`);
149
1367
  });
1368
+
1369
+ if (opts.render) {
1370
+ console.log("ticket list --render is deprecated; TICKET_LIST.md is no longer generated.");
1371
+ }
1372
+ }
1373
+
1374
+ export async function runTicketStatus(opts) {
1375
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1376
+ const found = pickTicketEntry(opts, index);
1377
+ if (!found) throw new Error("ticket status: no matching ticket found");
1378
+
1379
+ const absPath = join(opts.cwd, found.path);
1380
+ const fileMissing = !existsSync(absPath);
1381
+ const body = fileMissing ? "" : readFileSync(absPath, "utf8");
1382
+ const parsed = fileMissing ? { meta: {}, content: "" } : parseFrontMatter(body);
1383
+ if (!fileMissing) assertTicketLifecycleProvenance(found, parsed.meta);
1384
+ const phase = Number(parsed.meta.phase || 1);
1385
+ const incompleteReasons = getPhase1IncompleteReasons(opts.cwd, absPath);
1386
+ const derivedStatus = incompleteReasons.length > 0 && phase === 1
1387
+ ? "phase1_incomplete"
1388
+ : (parsed.meta.status || found.status || "open");
1389
+
1390
+ const out = {
1391
+ id: found.id,
1392
+ title: found.title,
1393
+ path: found.path,
1394
+ phase,
1395
+ status: derivedStatus,
1396
+ summary: parsed.meta.summary || null,
1397
+ reasons: incompleteReasons,
1398
+ };
1399
+
1400
+ if (opts.json) {
1401
+ console.log(JSON.stringify(out, null, 2));
1402
+ return;
1403
+ }
1404
+
1405
+ if (isCompactTicketOutput(opts)) {
1406
+ const reasonText = out.reasons.length === 0 ? "ok" : out.reasons.join(", ");
1407
+ console.log(`${out.id} | phase=${out.phase} | status=${out.status} | ${reasonText}`);
1408
+ return;
1409
+ }
1410
+
1411
+ console.log(`Ticket: ${out.id}`);
1412
+ console.log(`Status: ${out.status}`);
1413
+ console.log(`Phase: ${out.phase}`);
1414
+ console.log(`Path: ${out.path}`);
1415
+ if (opts.statusDetail || out.reasons.length > 0) {
1416
+ if (out.reasons.length === 0) console.log("Reasons: none");
1417
+ else console.log(`Reasons: ${out.reasons.join(", ")}`);
1418
+ }
1419
+ }
1420
+
1421
+ export async function runTicketHandoff(opts) {
1422
+ if (!opts.topic && !opts.latest) opts.latest = true;
1423
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1424
+ const current = pickTicketEntry(opts, index);
1425
+ if (!current) throw new Error("ticket handoff: no matching ticket found");
1426
+
1427
+ const currentAbs = join(opts.cwd, current.path);
1428
+ const currentMissing = !existsSync(currentAbs);
1429
+ const currentBody = currentMissing ? "" : readFileSync(currentAbs, "utf8");
1430
+ const currentParsed = currentMissing ? { meta: {}, content: "" } : parseFrontMatter(currentBody);
1431
+ const currentPhase = Number(currentParsed.meta.phase || 1);
1432
+ const currentReasons = currentMissing ? ["ticket_file_missing"] : getPhase1IncompleteReasons(opts.cwd, currentAbs);
1433
+ const currentStatus = currentReasons.length > 0 && currentPhase === 1
1434
+ ? "phase1_incomplete"
1435
+ : (currentParsed.meta.status || current.status || "open");
1436
+
1437
+ const rows = filterTicketEntries(index.entries, opts)
1438
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
1439
+ let nextTicket = rows.find(e => e.status === "active" && e.id !== current.id);
1440
+ if (!nextTicket) nextTicket = rows.find(e => e.status === "open" && e.id !== current.id);
1441
+
1442
+ const out = {
1443
+ current: {
1444
+ id: current.id,
1445
+ phase: currentPhase,
1446
+ status: currentStatus,
1447
+ path: current.path,
1448
+ reasons: currentReasons
1449
+ },
1450
+ nextTicket: nextTicket ? {
1451
+ id: nextTicket.id,
1452
+ status: nextTicket.status,
1453
+ path: nextTicket.path
1454
+ } : null,
1455
+ nextAction: nextTicket ? "continue-ticket" : "inspect-git-history"
1456
+ };
1457
+
1458
+ if (opts.json) {
1459
+ console.log(JSON.stringify(out, null, 2));
1460
+ return out;
1461
+ }
1462
+
1463
+ if (isCompactTicketOutput(opts)) {
1464
+ console.log(getHandoffSummary(out));
1465
+ return out;
1466
+ }
1467
+
1468
+ console.log(`Current: ${out.current.id} | phase=${out.current.phase} | status=${out.current.status}`);
1469
+ console.log(`Next: ${out.nextTicket ? `${out.nextTicket.id} (${out.nextTicket.status})` : "none"}`);
1470
+ console.log(`Action: ${out.nextAction}`);
1471
+ return out;
150
1472
  }
151
1473
 
152
1474
  export async function runTicketMeta(opts) {
153
- const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
1475
+ applyTicketContext(opts);
1476
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
154
1477
  const found = pickTicketEntry(opts, index);
155
1478
  if (!found) throw new Error("ticket meta: no matching ticket found");
156
1479
 
@@ -176,175 +1499,625 @@ export async function runTicketConnect(opts) {
176
1499
  }
177
1500
  }
178
1501
 
1502
+ export async function runTicketEvidenceCheck(opts) {
1503
+ applyTicketContext(opts);
1504
+ if (!opts.claim || !String(opts.claim).trim()) {
1505
+ throw new Error("ticket evidence requires --claim <text> to compare with ticket content.");
1506
+ }
1507
+
1508
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1509
+ const target = pickTicketEntry(opts, index);
1510
+ if (!target) {
1511
+ throw new Error("ticket evidence: no matching ticket found.");
1512
+ }
1513
+
1514
+ const absPath = join(opts.cwd, target.path);
1515
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1516
+ const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1517
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1518
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1519
+
1520
+ if (!result.ok || !implementationGuard.ok) {
1521
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
1522
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.topic} has insufficient evidence coverage for claim "${opts.claim}": ${reasons.join(", ")}.`);
1523
+ }
1524
+
1525
+ if (opts.json) {
1526
+ console.log(JSON.stringify(result, null, 2));
1527
+ } else {
1528
+ console.log(`[evidence-ok] ${target.topic} claim coverage ${result.coveredTerms}/${result.claimTerms}`);
1529
+ }
1530
+ }
1531
+
1532
+ export async function runTicketEvidenceReport(opts) {
1533
+ applyTicketContext(opts);
1534
+ if (!opts.claim || !String(opts.claim).trim()) {
1535
+ throw new Error("ticket report requires --claim <text> when generating a claim-bound report.");
1536
+ }
1537
+
1538
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1539
+ const target = pickTicketEntry(opts, index);
1540
+ if (!target) {
1541
+ throw new Error("ticket report: no matching ticket found.");
1542
+ }
1543
+
1544
+ const absPath = join(opts.cwd, target.path);
1545
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1546
+ const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1547
+ const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1548
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1549
+
1550
+ if (!result.ok || !implementationGuard.ok) {
1551
+ const reasons = [...result.reasons, ...(implementationGuard.reasons || [])];
1552
+ throw new Error(`[VALIDATION FAILED] Ticket ${target.topic} cannot produce claim-bound report for "${opts.claim}": ${reasons.join(", ")}.`);
1553
+ }
1554
+
1555
+ if (opts.json) {
1556
+ console.log(JSON.stringify(result, null, 2));
1557
+ return;
1558
+ }
1559
+
1560
+ console.log(`Claim-bound ticket report: ${target.topic}`);
1561
+ console.log(`Claim: ${opts.claim}`);
1562
+ console.log(`Coverage: ${result.coveredTerms}/${result.claimTerms}`);
1563
+ for (const [label, value] of Object.entries(result.sections)) {
1564
+ if (!value) continue;
1565
+ console.log(`\n## ${label}`);
1566
+ console.log(value);
1567
+ }
1568
+ }
1569
+
179
1570
 
180
1571
  export async function runTicketClose(opts) {
1572
+ applyTicketContext(opts);
181
1573
  if (!opts.topic && !opts.latest) {
182
- if (process.stdout.isTTY) {
183
- const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
184
- const choices = index.entries
185
- .filter(e => e.status !== "closed")
186
- .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
187
- if (choices.length > 0) {
188
- const rl = createInterface({ input: process.stdin, output: process.stdout });
189
- try {
1574
+ if (opts.nonInteractive || !process.stdout.isTTY) {
1575
+ opts.latest = true;
1576
+ } else {
1577
+ await withReadline(async (rl) => {
1578
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1579
+ const choices = index.entries
1580
+ .filter(e => e.status !== "closed" && e.status !== "cancelled")
1581
+ .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
1582
+ if (choices.length > 0) {
190
1583
  opts.topic = await selectOne(rl, "Choose a ticket to close:", choices);
191
- } finally {
192
- rl.close();
1584
+ } else {
1585
+ throw new Error("No open tickets found to close.");
193
1586
  }
194
- } else {
195
- throw new Error("No open tickets found to close.");
196
- }
197
- } else {
198
- throw new Error("ticket close requires --topic or --latest");
1587
+ });
199
1588
  }
200
1589
  }
201
- opts.status = "closed";
202
- const entry = updateTicketEntryStatus(opts.cwd, opts);
203
- syncActiveTicketId(opts.cwd);
204
- console.log(`ticket: closed -> ${entry.topic} (${entry.path})`);
1590
+ // Respect --status flag (e.g. 'cancelled', 'wontfix'); default to 'closed'
1591
+ if (!opts.status) opts.status = "closed";
1592
+ const previousIndex = readTicketIndexJson(opts.cwd);
1593
+ const targetEntry = pickTicketEntry(opts, previousIndex);
1594
+ if (!targetEntry) {
1595
+ throw new Error("No matching ticket found to update status");
1596
+ }
1597
+
1598
+ const abs = join(opts.cwd, targetEntry.path);
1599
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + targetEntry.path);
1600
+ const previousBody = readFileSync(abs, "utf8");
1601
+ const parsedForClose = parseFrontMatter(previousBody);
1602
+ const closePlanningReasons = [
1603
+ ...getAnalysisDesignIncompleteReasons(parsedForClose.meta, parsedForClose.content),
1604
+ ...getMainTicketEvidenceReasons(parsedForClose.meta, parsedForClose.content)
1605
+ ];
1606
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { content: parsedForClose.content, changedFiles: opts.changedFiles });
1607
+ if (!implementationGuard.ok) {
1608
+ closePlanningReasons.push(...implementationGuard.reasons);
1609
+ }
1610
+ if (closePlanningReasons.length) {
1611
+ throw new Error(`[VALIDATION FAILED] Ticket ${targetEntry.topic} cannot close without complete main-ticket analysis/design evidence: ${[...new Set(closePlanningReasons)].join(", ")}.`);
1612
+ }
1613
+
1614
+ try {
1615
+ const entry = updateTicketEntryStatus(opts.cwd, opts);
1616
+ const { meta } = parseFrontMatter(previousBody);
1617
+ const lintTargets = collectTicketLifecycleMarkdownTargets(opts.cwd, abs);
1618
+ lintTicketLifecycleMarkdown(opts.cwd, lintTargets, `ticket close ${entry.topic}`);
1619
+ syncActiveTicketId(opts.cwd);
1620
+ appendTelemetryEvent(opts.cwd, {
1621
+ event: "ticket_closed",
1622
+ action: "ticket-close",
1623
+ ticket: entry.id || entry.topic,
1624
+ file: entry.path,
1625
+ phase: 4,
1626
+ status: opts.status
1627
+ });
1628
+ console.log(`ticket: ${opts.status} -> ${entry.topic} (${entry.path})`);
1629
+ } catch (err) {
1630
+ rollbackTicketLifecycleArtifacts(opts.cwd, previousIndex, previousBody, abs, opts);
1631
+ throw err;
1632
+ }
205
1633
  }
206
1634
 
207
1635
  export async function runTicketUse(opts) {
208
- const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
209
- syncActiveTicketId(opts.cwd);
1636
+ applyTicketContext(opts);
1637
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
210
1638
 
211
1639
  let targetTopic = opts.topic;
212
1640
  if (!targetTopic && !opts.latest) {
213
- if (process.stdout.isTTY) {
1641
+ if (opts.nonInteractive) {
1642
+ throw new Error("ticket use: --topic or --latest is required in non-interactive mode.");
1643
+ }
1644
+ await withReadline(async (rl) => {
214
1645
  const choices = index.entries
215
1646
  .map(e => ({ label: `${e.status === 'closed' ? '✓ ' : ''}[${e.group}] ${e.title}`, value: e.topic }));
216
1647
  if (choices.length > 0) {
217
- const rl = createInterface({ input: process.stdin, output: process.stdout });
218
- try {
219
- targetTopic = await selectOne(rl, "Choose a ticket to use:", choices);
220
- } finally {
221
- rl.close();
222
- }
1648
+ targetTopic = await selectOne(rl, "Choose a ticket to use:", choices);
1649
+ }
1650
+ });
1651
+ }
1652
+
1653
+ const found = opts.latest ? index.entries[0] : index.entries.find(e =>
1654
+ String(e.topic || "").includes(targetTopic) ||
1655
+ String(e.id || "").includes(targetTopic)
1656
+ );
1657
+ if (!found) {
1658
+ const candidates = buildUseFallbackCandidates(index);
1659
+ if (!opts.nonInteractive && candidates.length > 0) {
1660
+ await withReadline(async (rl) => {
1661
+ targetTopic = await selectOne(
1662
+ rl,
1663
+ `No matching ticket found for "${targetTopic}". Choose a ticket to use:`,
1664
+ candidates.map(e => ({ label: formatTicketChoice(e), value: e.topic || e.id }))
1665
+ );
1666
+ });
1667
+ const selected = index.entries.find(e => e.topic === targetTopic || e.id === targetTopic);
1668
+ if (selected) {
1669
+ opts.topic = targetTopic;
1670
+ return runTicketUse({ ...opts, latest: false });
223
1671
  }
224
1672
  }
1673
+ throw new Error(buildUseNoMatchError(targetTopic, candidates));
225
1674
  }
226
1675
 
227
- const found = opts.latest ? index.entries[0] : index.entries.find(e => e.topic.includes(targetTopic));
228
- if (!found) throw new Error("No matching ticket found");
1676
+ const foundAbsPath = join(opts.cwd, found.path);
1677
+ if (!existsSync(foundAbsPath)) throw new Error("Ticket file not found: " + found.path);
1678
+ const foundParsed = parseFrontMatter(readFileSync(foundAbsPath, "utf8"));
1679
+ assertTicketLifecycleProvenance(found, foundParsed.meta);
229
1680
 
230
- if (opts.pathOnly) console.log(found.path);
1681
+ // Explicitly set activeTicketId to the selected ticket
1682
+ if (index.activeTicketId !== found.id) {
1683
+ writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
1684
+ }
1685
+
1686
+ const posixPath = toPosixPath(found.path);
1687
+ const absPath = toPosixPath(join(opts.cwd, found.path));
1688
+ if (opts.pathOnly) console.log(absPath);
231
1689
  else {
232
- console.log(`Path: ${found.path}`);
1690
+ console.log(`Active ticket: ${found.id}`);
1691
+ console.log(`Path: [${posixPath}](file://${absPath})`);
233
1692
  if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
234
1693
  }
235
1694
  }
236
1695
 
237
1696
 
238
1697
 
1698
+ function extractMarkdownSections(content, sectionNames) {
1699
+ const sections = {};
1700
+ for (const name of sectionNames) {
1701
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1702
+ const regex = new RegExp(`^##\\s+${escapedName}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
1703
+ const match = content.match(regex);
1704
+ if (match) {
1705
+ const value = match[1].trim();
1706
+ if (value) sections[name] = value;
1707
+ }
1708
+ }
1709
+ return sections;
1710
+ }
1711
+
1712
+ function distillKnowledge(absPath, ticketId, cwd, sourceBody = null) {
1713
+ try {
1714
+ const body = sourceBody !== null ? sourceBody : readFileSync(absPath, "utf8");
1715
+ const { meta, content } = parseFrontMatter(body);
1716
+ const ticketSections = extractMarkdownSections(content, [
1717
+ "Scope & Constraints",
1718
+ "Agent Permission Contract (APC)",
1719
+ "Compact Plan",
1720
+ "Tasks",
1721
+ "Done When",
1722
+ "Design Decisions",
1723
+ "Analysis & Constraints"
1724
+ ]);
1725
+ const knowledgeDir = join(cwd, AGENT_ROOT_DIR, "knowledge");
1726
+ if (!existsSync(knowledgeDir)) mkdirSync(knowledgeDir, { recursive: true });
1727
+
1728
+ const dest = join(knowledgeDir, `${ticketId}.json`);
1729
+ const data = {
1730
+ id: ticketId,
1731
+ title: meta.title || ticketId,
1732
+ project: meta.project || "global",
1733
+ createdAt: meta.createdAt,
1734
+ archivedAt: new Date().toISOString(),
1735
+ summary: meta.summary || "",
1736
+ sourceKind: "ticket",
1737
+ ingestionCategory: "archived_ticket",
1738
+ corpus: "tickets",
1739
+ originTool: "ticket-archive",
1740
+ freshness: "archived",
1741
+ refreshPolicy: "refresh-on-stale",
1742
+ sourceTicketPath: toRepoRelativePath(cwd, absPath),
1743
+ sections: ticketSections,
1744
+ analysis: {}
1745
+ };
1746
+
1747
+ writeFileSync(dest, JSON.stringify(data, null, 2), "utf8");
1748
+ console.log(`Knowledge distilled to ${toFileUri(dest)}`);
1749
+ appendTelemetryEvent(cwd, {
1750
+ event: "knowledge_distilled",
1751
+ action: "knowledge-distill",
1752
+ ticket: ticketId,
1753
+ file: toRepoRelativePath(cwd, absPath),
1754
+ knowledgeAction: "add_knowledge",
1755
+ knowledgeSourceKind: data.sourceKind,
1756
+ knowledgeIngestionCategory: data.ingestionCategory,
1757
+ knowledgeCorpus: data.corpus,
1758
+ knowledgeOriginTool: data.originTool,
1759
+ knowledgeFreshness: data.freshness,
1760
+ tokenQuality: "saved"
1761
+ });
1762
+ } catch (err) {
1763
+ console.warn(`[WARNING] Knowledge distillation failed for ${ticketId}: ${err.message}`);
1764
+ }
1765
+ }
1766
+
1767
+ function appendTelemetryEvent(cwd, entry) {
1768
+ try {
1769
+ appendInternalWorkflowEvent(cwd, {
1770
+ event: entry.event || "workflow_event",
1771
+ ticket: entry.ticket || "",
1772
+ action: entry.action || entry.event || "workflow-event",
1773
+ file: entry.file || "",
1774
+ phase: entry.phase,
1775
+ status: entry.status || "",
1776
+ ragResult: entry.ragResult || "",
1777
+ localFallback: Boolean(entry.localFallback),
1778
+ knowledgeAction: entry.knowledgeAction || "",
1779
+ knowledgeSourceKind: entry.knowledgeSourceKind || "",
1780
+ knowledgeIngestionCategory: entry.knowledgeIngestionCategory || "",
1781
+ knowledgeCorpus: entry.knowledgeCorpus || "",
1782
+ knowledgeOriginTool: entry.knowledgeOriginTool || "",
1783
+ knowledgeFreshness: entry.knowledgeFreshness || "",
1784
+ tokenQuality: entry.tokenQuality || "",
1785
+ savedTokens: Number(entry.savedTokens || 0)
1786
+ });
1787
+ } catch (err) {
1788
+ console.warn(`[WARNING] Telemetry append failed for ${entry.ticket || "unknown"}: ${err.message}`);
1789
+ }
1790
+ }
1791
+
239
1792
  export function pickTicketEntry(opts, indexJson) {
240
- const rows = [...indexJson.entries].sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
1793
+ const rows = filterTicketEntries(indexJson.entries, opts)
1794
+ .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
241
1795
  if (rows.length === 0) return null;
242
1796
  if (opts.topic) {
243
1797
  const key = String(opts.topic).toLowerCase();
244
- return rows.find(e => String(e.topic || "").toLowerCase().includes(key)) || null;
1798
+ return rows.find(e =>
1799
+ String(e.topic || "").toLowerCase().includes(key) ||
1800
+ String(e.id || "").toLowerCase().includes(key)
1801
+ ) || null;
245
1802
  }
246
1803
  return rows[0];
247
1804
  }
248
1805
 
1806
+ function filterTicketEntries(entries, opts = {}) {
1807
+ return [...(entries || [])].filter(entry => {
1808
+ if (opts.project && entry.project !== opts.project) return false;
1809
+ if (opts.submodule && entry.submodule !== opts.submodule) return false;
1810
+ return true;
1811
+ });
1812
+ }
1813
+
249
1814
  export async function runTicketArchive(opts) {
250
- const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, opts);
1815
+ applyTicketContext(opts);
1816
+ const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
251
1817
  const ticketDir = detectConsumerTicketDir(opts.cwd);
252
1818
 
253
1819
  if (!opts.latest && !opts.topic) {
254
- if (process.stdout.isTTY) {
1820
+ if (opts.nonInteractive) {
1821
+ throw new Error("ticket archive: --topic or --latest is required in non-interactive mode.");
1822
+ }
1823
+ await withReadline(async (rl) => {
255
1824
  const choices = indexJson.entries
256
1825
  .filter(e => e.status !== "archived")
257
1826
  .map(e => ({ label: `[${e.group}] ${e.title}`, value: e.topic }));
258
1827
  if (choices.length > 0) {
259
- const rl = createInterface({ input: process.stdin, output: process.stdout });
260
- try {
261
- opts.topic = await selectOne(rl, "Choose a ticket to archive (this will move the file to archive/):", choices);
262
- } finally {
263
- rl.close();
264
- }
1828
+ opts.topic = await selectOne(rl, "Choose a ticket to archive (this will move the file to archive/):", choices);
265
1829
  } else {
266
1830
  throw new Error("No active tickets found to archive.");
267
1831
  }
268
- } else {
269
- throw new Error("ticket archive requires --latest or --topic <prefix>");
270
- }
1832
+ });
271
1833
  }
272
1834
 
273
1835
  const found = pickTicketEntry(opts, indexJson);
274
1836
  if (!found) throw new Error("ticket archive: no matching entry");
275
1837
 
276
- const absPath = join(opts.cwd, found.path);
277
- if (!existsSync(absPath)) {
278
- throw new Error("ticket archive: file not found " + found.path);
279
- }
280
-
281
- const archiveDir = join(ticketDir, "archive", found.group || "sub");
282
- if (!opts.dryRun) mkdirSync(archiveDir, { recursive: true });
283
-
284
1838
  const fileName = found.path.split(/[/\\]/).pop();
285
- const newAbsPath = join(archiveDir, fileName);
286
- const bodyLines = readFileSync(absPath, "utf8").trimEnd().split(/\r?\n/);
287
1839
 
288
- if (opts.report) {
289
- const reportSrc = resolve(opts.cwd, opts.report);
290
- if (!existsSync(reportSrc)) {
291
- throw new Error("ticket archive: report file not found " + opts.report);
1840
+ // Auto-search for report if not provided
1841
+ let report = opts.report;
1842
+ if (!opts.report) {
1843
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
1844
+ const potentialReport = fileName.replace(/\.md$/i, "-report.md");
1845
+ const potentialPath = join(reportDir, potentialReport);
1846
+ if (existsSync(potentialPath)) {
1847
+ report = toRepoRelativePath(opts.cwd, potentialPath);
1848
+ console.log(`ticket archive: auto-detected report at ${report}`);
292
1849
  }
293
- const reportDir = join(ticketDir, "reports");
294
- if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
295
-
296
- const reportDest = join(reportDir, `REPORT-${fileName}`);
297
- if (!opts.dryRun) copyFileSync(reportSrc, reportDest);
298
- console.log("ticket archive: copied report to " + toRepoRelativePath(opts.cwd, reportDest));
299
-
300
- bodyLines.push("");
301
- bodyLines.push("## 📄 Attached Report");
302
- const relativeLink = toPosixPath(relative(dirname(newAbsPath), reportDest));
303
- bodyLines.push(`- [View Report](${relativeLink})`);
304
- }
305
-
306
- if (opts.dryRun) {
307
- console.log("ticket archive: would move " + toRepoRelativePath(opts.cwd, absPath) + " to " + toRepoRelativePath(opts.cwd, newAbsPath));
308
- return;
309
1850
  }
310
1851
 
311
- writeFileSync(newAbsPath, bodyLines.join("\n") + "\n", "utf8");
312
- rmSync(absPath);
313
- console.log("ticket archive: moved ticket to " + toRepoRelativePath(opts.cwd, newAbsPath));
314
-
315
- const entryIdx = indexJson.entries.findIndex(e => e.id === found.id);
316
- if (entryIdx >= 0) {
317
- indexJson.entries[entryIdx].status = "archived";
318
- indexJson.entries[entryIdx].path = toRepoRelativePath(opts.cwd, newAbsPath);
319
- indexJson.entries[entryIdx].updatedAt = new Date().toISOString();
320
- }
1852
+ const result = archiveTicketEntry({ cwd: opts.cwd, ticketDir, indexJson, found, opts, report });
1853
+ if (opts.dryRun) return;
321
1854
 
322
1855
  writeTicketIndexJson(opts.cwd, indexJson, opts);
323
- if (opts.render) writeTicketListFile(opts.cwd, indexJson.entries, opts);
324
1856
  syncActiveTicketId(opts.cwd);
1857
+ if (result?.id) {
1858
+ appendTelemetryEvent(opts.cwd, {
1859
+ event: "ticket_archived",
1860
+ action: "ticket-archive",
1861
+ ticket: result.id,
1862
+ file: result.path,
1863
+ status: "archived"
1864
+ });
1865
+ }
1866
+ return result;
325
1867
  }
326
1868
 
327
1869
  export async function runTicketReports(opts) {
328
1870
  const ticketDir = detectConsumerTicketDir(opts.cwd);
329
1871
  if (!ticketDir) throw new Error("No ticket system found.");
330
- const reportDir = join(ticketDir, "reports");
1872
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
331
1873
  console.log("\n📄 Agent Reports:");
332
1874
  if (!existsSync(reportDir)) {
333
1875
  console.log(" No reports found.");
334
1876
  return;
335
1877
  }
336
- const files = readdirSync(reportDir).filter(f => f.startsWith("REPORT-") && f.endsWith(".md"));
1878
+ const files = readdirSync(reportDir).filter(f => f.endsWith("-report.md"));
337
1879
  if (files.length === 0) {
338
1880
  console.log(" No reports found.");
339
1881
  return;
340
1882
  }
341
1883
 
1884
+ const index = readTicketIndexJson(opts.cwd);
342
1885
  const sorted = files.sort((a, b) => {
343
1886
  return statSync(join(reportDir, b)).mtime.getTime() - statSync(join(reportDir, a)).mtime.getTime();
344
1887
  });
345
1888
 
346
1889
  sorted.slice(0, opts.limit || 30).forEach(f => {
347
- console.log(` - [${f}]`);
1890
+ const ticketId = f.replace(/-report\.md$/i, "");
1891
+ const entry = index.entries.find(e => e.id === ticketId || e.topic === ticketId);
1892
+ const status = entry ? ` [${entry.status}]` : "";
1893
+ console.log(` - [${f}](${toFileUri(join(reportDir, f))})${status}`);
348
1894
  });
349
1895
  console.log("");
350
1896
  }
1897
+
1898
+ export async function runTicketReportAttach(opts) {
1899
+ applyTicketContext(opts);
1900
+ if (!opts.report) throw new Error("ticket report attach requires --report <file_path>");
1901
+
1902
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1903
+ const found = pickTicketEntry(opts, index);
1904
+ if (!found) throw new Error("ticket report attach: no matching ticket found");
1905
+
1906
+ const absTicketPath = join(opts.cwd, found.path);
1907
+ if (!existsSync(absTicketPath)) throw new Error("Ticket file not found: " + found.path);
1908
+
1909
+ const reportSrc = resolve(opts.cwd, opts.report);
1910
+ if (!existsSync(reportSrc)) throw new Error("Report file not found: " + opts.report);
1911
+
1912
+ const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
1913
+ if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
1914
+
1915
+ const ticketFileName = found.path.split(/[/\\]/).pop();
1916
+ const reportBaseName = ticketFileName.replace(/\.md$/i, "-report.md");
1917
+ const reportDest = join(reportDir, reportBaseName);
1918
+
1919
+ if (!opts.dryRun) {
1920
+ copyFileSync(reportSrc, reportDest);
1921
+
1922
+ // Update ticket content to link the report
1923
+ let body = readFileSync(absTicketPath, "utf8").trimEnd();
1924
+ if (!body.includes("## 📄 Attached Report")) {
1925
+ const relativeLink = toPosixPath(relative(dirname(absTicketPath), reportDest));
1926
+ body += `\n\n## 📄 Attached Report\n- [View Report](${relativeLink})\n`;
1927
+ writeFileSync(absTicketPath, body, "utf8");
1928
+ }
1929
+ console.log(`ticket report: attached ${toRepoRelativePath(opts.cwd, reportSrc)} to ${found.id}`);
1930
+ } else {
1931
+ console.log(`ticket report: would attach ${toRepoRelativePath(opts.cwd, reportSrc)} to ${found.id}`);
1932
+ }
1933
+ }
1934
+
1935
+ export async function runTicketRebuild(opts) {
1936
+ console.log("Rebuilding INDEX.json from markdown files...");
1937
+ rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: true, rebuild: true });
1938
+ }
1939
+
1940
+ export async function runTicketMove(opts) {
1941
+ applyTicketContext(opts);
1942
+ if (!opts.topic && !opts.latest) {
1943
+ if (opts.nonInteractive) {
1944
+ throw new Error("ticket move: --topic or --latest is required in non-interactive mode.");
1945
+ }
1946
+ opts.latest = true; // Default to latest
1947
+ }
1948
+
1949
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
1950
+ const entry = pickTicketEntry(opts, index);
1951
+
1952
+ if (!entry) throw new Error("No matching ticket found to move.");
1953
+
1954
+ const abs = join(opts.cwd, entry.path);
1955
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
1956
+
1957
+ const previousIndex = readTicketIndexJson(opts.cwd);
1958
+ const body = readFileSync(abs, "utf8");
1959
+ const { meta, content } = parseFrontMatter(body);
1960
+
1961
+ const currentPhase = meta.phase || 1;
1962
+ let nextPhase = opts.next ? currentPhase + 1 : (opts.phase || currentPhase + 1);
1963
+
1964
+ if (currentPhase === 1 && nextPhase >= 2) {
1965
+ const reasons = getPhase1IncompleteReasons(opts.cwd, abs);
1966
+ const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { content, changedFiles: opts.changedFiles });
1967
+ if (!implementationGuard.ok) {
1968
+ reasons.push(...implementationGuard.reasons);
1969
+ }
1970
+ if (reasons.length) {
1971
+ 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.`);
1972
+ }
1973
+ }
1974
+
1975
+ meta.phase = nextPhase;
1976
+ if (nextPhase >= 4) {
1977
+ meta.status = "closed";
1978
+ } else if (nextPhase >= 2 && (!meta.status || meta.status === "open")) {
1979
+ meta.status = "active";
1980
+ }
1981
+
1982
+ const newBody = stringifyFrontMatter(meta, content);
1983
+
1984
+ try {
1985
+ writeFileSync(abs, newBody, "utf8");
1986
+
1987
+ // Re-sync index to reflect the status change if any
1988
+ opts.topic = entry.topic;
1989
+ if (meta.status !== entry.status) {
1990
+ opts.status = meta.status;
1991
+ updateTicketEntryStatus(opts.cwd, opts);
1992
+ } else {
1993
+ rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: true });
1994
+ }
1995
+
1996
+ const lintTargets = collectTicketLifecycleMarkdownTargets(opts.cwd, abs);
1997
+ lintTicketLifecycleMarkdown(opts.cwd, lintTargets, `ticket move ${entry.topic}`);
1998
+
1999
+ syncActiveTicketId(opts.cwd);
2000
+ appendTelemetryEvent(opts.cwd, {
2001
+ event: "ticket_phase_moved",
2002
+ action: "ticket-move",
2003
+ ticket: entry.id || entry.topic,
2004
+ file: entry.path,
2005
+ phase: nextPhase,
2006
+ status: meta.status
2007
+ });
2008
+ console.log(`ticket: moved -> ${entry.topic} is now in Phase ${nextPhase} (${meta.status})`);
2009
+ } catch (err) {
2010
+ rollbackTicketLifecycleArtifacts(opts.cwd, previousIndex, body, abs, opts);
2011
+ throw err;
2012
+ }
2013
+ }
2014
+
2015
+ export async function runTicketNext(opts) {
2016
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2017
+ // Find the first active ticket, or if none, the first open ticket (earliest created)
2018
+ const rows = filterTicketEntries(index.entries, opts)
2019
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
2020
+ let found = rows.find(e => e.status === "active");
2021
+ if (!found) {
2022
+ found = rows.find(e => e.status === "open");
2023
+ }
2024
+
2025
+ if (!found) {
2026
+ throw new Error("No active or open tickets found to proceed with. Inspect recent git history before creating a follow-up ticket.");
2027
+ }
2028
+
2029
+ if (index.activeTicketId !== found.id) {
2030
+ writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
2031
+ }
2032
+
2033
+ const posixPath = toPosixPath(found.path);
2034
+ const absPath = toPosixPath(join(opts.cwd, found.path));
2035
+ if (opts.pathOnly) {
2036
+ console.log(absPath);
2037
+ } else {
2038
+ console.log(`Next ticket: ${found.id}`);
2039
+ console.log(`Path: [${posixPath}](file://${absPath})`);
2040
+ if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
2041
+ }
2042
+ }
2043
+
2044
+ export async function runTicketHotfix(opts) {
2045
+ if (!opts.topic && !opts.latest) {
2046
+ if (opts.nonInteractive) {
2047
+ throw new Error("ticket hotfix: --topic or --latest is required in non-interactive mode.");
2048
+ }
2049
+ opts.latest = true;
2050
+ }
2051
+
2052
+ if (!opts.reason) {
2053
+ throw new Error("[HOTFIX DENIED] A mandatory --reason must be provided to justify bypassing standard rules (e.g., 'codegen is broken').");
2054
+ }
2055
+
2056
+ // User explicit approval
2057
+ if (!opts.nonInteractive) {
2058
+ let proceed = false;
2059
+ await withReadline(async (rl) => {
2060
+ proceed = await new Promise(resolve => {
2061
+ rl.question(`\n⚠️ [EMERGENCY HOTFIX] This will bypass standard APC rules.\nReason: ${opts.reason}\nProceed? (y/N): `, a => {
2062
+ resolve(a.trim().toLowerCase() === 'y');
2063
+ });
2064
+ });
2065
+ });
2066
+ if (!proceed) {
2067
+ console.log('Hotfix cancelled by user.');
2068
+ return;
2069
+ }
2070
+ }
2071
+
2072
+ const index = rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: false });
2073
+ const entry = pickTicketEntry(opts, index);
2074
+
2075
+ if (!entry) throw new Error("No matching ticket found for hotfix.");
2076
+
2077
+ const abs = join(opts.cwd, entry.path);
2078
+ if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
2079
+
2080
+ const body = readFileSync(abs, "utf8");
2081
+ const { meta, content } = parseFrontMatter(body);
2082
+
2083
+ // Force phase 2 and active status, bypassing APC checks
2084
+ meta.phase = 2;
2085
+ meta.status = "active";
2086
+ meta.hotfix = true;
2087
+ meta.hotfixReason = opts.reason;
2088
+
2089
+ // Append hotfix record to content
2090
+ const timestamp = new Date().toISOString();
2091
+ const hotfixRecord = `\n\n> [!WARNING]\n> **EMERGENCY HOTFIX ACTIVATED** (${timestamp})\n> **Reason:** ${opts.reason}\n> Standard APC and Phase 1 guards were bypassed.\n`;
2092
+
2093
+ const newBody = stringifyFrontMatter(meta, content + hotfixRecord);
2094
+ writeFileSync(abs, newBody, "utf8");
2095
+
2096
+ // Re-sync index
2097
+ opts.topic = entry.topic;
2098
+ opts.status = "active";
2099
+ updateTicketEntryStatus(opts.cwd, opts);
2100
+
2101
+ syncActiveTicketId(opts.cwd);
2102
+ console.log(`[EMERGENCY HOTFIX] Ticket ${entry.topic} is now ACTIVE. Rule guardrails bypassed for this session.`);
2103
+
2104
+ // Auto-create derivation ticket
2105
+ const deriveTopic = `codegen-fix-${entry.topic}`;
2106
+ const deriveSummary = `[DERIVED] Fix CodeGen source for hotfix: ${opts.reason}`;
2107
+ console.log(`[HOTFIX] Auto-creating derivation ticket: ${deriveTopic}`);
2108
+
2109
+ try {
2110
+ await runTicketCreate({
2111
+ cwd: opts.cwd,
2112
+ topic: deriveTopic,
2113
+ summary: deriveSummary,
2114
+ chain: true,
2115
+ tags: 'hotfix-derived,codegen',
2116
+ priority: 'P1',
2117
+ skipPhase0: true,
2118
+ nonInteractive: true
2119
+ });
2120
+ } catch (err) {
2121
+ console.warn(`[WARNING] Failed to auto-create derivation ticket: ${err.message}`);
2122
+ }
2123
+ }