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