deuk-agent-flow 5.0.2 → 5.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.ko.md +45 -0
  2. package/CHANGELOG.md +48 -0
  3. package/bundled/deuk-agent-flow.vsix +0 -0
  4. package/core-rules/AGENTS.md +5 -5
  5. package/package.json +20 -2
  6. package/scripts/bundle-vscode-vsix.ts +1 -1
  7. package/scripts/cli-args.ts +46 -1
  8. package/scripts/cli-context-commands.ts +35 -0
  9. package/scripts/cli-init-claude.ts +179 -0
  10. package/scripts/cli-init-commands.ts +21 -8
  11. package/scripts/cli-init-core.ts +89 -0
  12. package/scripts/cli-init-instructions.ts +489 -0
  13. package/scripts/cli-init-migrate.ts +504 -0
  14. package/scripts/cli-init-workspace.ts +63 -0
  15. package/scripts/cli-locales.ts +85 -0
  16. package/scripts/cli-skill-commands.ts +3 -1
  17. package/scripts/cli-ticket-commands.ts +68 -2
  18. package/scripts/cli-ticket-docmeta.ts +103 -0
  19. package/scripts/cli-ticket-document.ts +153 -0
  20. package/scripts/cli-ticket-evidence.ts +312 -0
  21. package/scripts/cli-ticket-home.ts +449 -0
  22. package/scripts/cli-ticket-index.ts +8 -0
  23. package/scripts/cli-ticket-scan.ts +102 -0
  24. package/scripts/cli-ticket-surface.ts +56 -0
  25. package/scripts/cli.ts +10 -1
  26. package/scripts/oss-prepack.js +32 -0
  27. package/scripts/out/scripts/bundle-vscode-vsix.js +68 -0
  28. package/scripts/out/scripts/bundle-vscode-vsix.js.map +1 -0
  29. package/scripts/out/scripts/cli-args.js +411 -0
  30. package/scripts/out/scripts/cli-args.js.map +1 -0
  31. package/scripts/out/scripts/cli-context-commands.js +31 -0
  32. package/scripts/out/scripts/cli-context-commands.js.map +1 -0
  33. package/scripts/out/scripts/cli-init-claude.js +176 -0
  34. package/scripts/out/scripts/cli-init-claude.js.map +1 -0
  35. package/scripts/out/scripts/cli-init-commands.js +98 -0
  36. package/scripts/out/scripts/cli-init-commands.js.map +1 -0
  37. package/scripts/out/scripts/cli-init-core.js +89 -0
  38. package/scripts/out/scripts/cli-init-core.js.map +1 -0
  39. package/scripts/out/scripts/cli-init-instructions.js +460 -0
  40. package/scripts/out/scripts/cli-init-instructions.js.map +1 -0
  41. package/scripts/out/scripts/cli-init-logic.js +41 -0
  42. package/scripts/out/scripts/cli-init-logic.js.map +1 -0
  43. package/scripts/out/scripts/cli-init-migrate.js +497 -0
  44. package/scripts/out/scripts/cli-init-migrate.js.map +1 -0
  45. package/scripts/out/scripts/cli-init-workspace.js +57 -0
  46. package/scripts/out/scripts/cli-init-workspace.js.map +1 -0
  47. package/scripts/out/scripts/cli-locales.js +83 -0
  48. package/scripts/out/scripts/cli-locales.js.map +1 -0
  49. package/scripts/out/scripts/cli-prompts.js +101 -0
  50. package/scripts/out/scripts/cli-prompts.js.map +1 -0
  51. package/scripts/out/scripts/cli-rule-compiler.js +121 -0
  52. package/scripts/out/scripts/cli-rule-compiler.js.map +1 -0
  53. package/scripts/out/scripts/cli-skill-commands.js +708 -0
  54. package/scripts/out/scripts/cli-skill-commands.js.map +1 -0
  55. package/scripts/out/scripts/cli-telemetry-commands.js +604 -0
  56. package/scripts/out/scripts/cli-telemetry-commands.js.map +1 -0
  57. package/scripts/out/scripts/cli-ticket-command-shared.js +2 -0
  58. package/scripts/out/scripts/cli-ticket-command-shared.js.map +1 -0
  59. package/scripts/out/scripts/cli-ticket-commands.js +3538 -0
  60. package/scripts/out/scripts/cli-ticket-commands.js.map +1 -0
  61. package/scripts/out/scripts/cli-ticket-docmeta.js +90 -0
  62. package/scripts/out/scripts/cli-ticket-docmeta.js.map +1 -0
  63. package/scripts/out/scripts/cli-ticket-document.js +121 -0
  64. package/scripts/out/scripts/cli-ticket-document.js.map +1 -0
  65. package/scripts/out/scripts/cli-ticket-evidence.js +286 -0
  66. package/scripts/out/scripts/cli-ticket-evidence.js.map +1 -0
  67. package/scripts/out/scripts/cli-ticket-home.js +440 -0
  68. package/scripts/out/scripts/cli-ticket-home.js.map +1 -0
  69. package/scripts/out/scripts/cli-ticket-index.js +280 -0
  70. package/scripts/out/scripts/cli-ticket-index.js.map +1 -0
  71. package/scripts/out/scripts/cli-ticket-migration.js +261 -0
  72. package/scripts/out/scripts/cli-ticket-migration.js.map +1 -0
  73. package/scripts/out/scripts/cli-ticket-parser.js +90 -0
  74. package/scripts/out/scripts/cli-ticket-parser.js.map +1 -0
  75. package/scripts/out/scripts/cli-ticket-scan.js +93 -0
  76. package/scripts/out/scripts/cli-ticket-scan.js.map +1 -0
  77. package/scripts/out/scripts/cli-ticket-surface.js +53 -0
  78. package/scripts/out/scripts/cli-ticket-surface.js.map +1 -0
  79. package/scripts/out/scripts/cli-usage-commands.js +310 -0
  80. package/scripts/out/scripts/cli-usage-commands.js.map +1 -0
  81. package/scripts/out/scripts/cli-utils.js +1544 -0
  82. package/scripts/out/scripts/cli-utils.js.map +1 -0
  83. package/scripts/out/scripts/cli.js +705 -0
  84. package/scripts/out/scripts/cli.js.map +1 -0
  85. package/scripts/out/scripts/lint-md.js +238 -0
  86. package/scripts/out/scripts/lint-md.js.map +1 -0
  87. package/scripts/out/scripts/lint-rules.js +193 -0
  88. package/scripts/out/scripts/lint-rules.js.map +1 -0
  89. package/scripts/out/scripts/merge-logic.js +38 -0
  90. package/scripts/out/scripts/merge-logic.js.map +1 -0
  91. package/scripts/out/scripts/plan-parser.js +52 -0
  92. package/scripts/out/scripts/plan-parser.js.map +1 -0
  93. package/scripts/out/scripts/ticket-machine.js +94 -0
  94. package/scripts/out/scripts/ticket-machine.js.map +1 -0
  95. package/scripts/out/scripts/ticket-workflow.js +498 -0
  96. package/scripts/out/scripts/ticket-workflow.js.map +1 -0
  97. package/scripts/ticket-machine.ts +99 -0
  98. package/scripts/ticket-workflow.ts +544 -0
  99. package/templates/skills/role-closer/SKILL.md +27 -0
  100. package/templates/skills/role-implementer/SKILL.md +30 -0
  101. package/templates/skills/role-planner/SKILL.md +29 -0
  102. package/templates/skills/role-qa-verifier/SKILL.md +30 -0
@@ -0,0 +1,99 @@
1
+ // #691: XState 기반 티켓 워크플로 상태머신.
2
+ //
3
+ // LangGraph 형태 전환의 핵심 — 직접 구현한 전이 분기(미정의 전이 허용·guard 우회 같은
4
+ // 구멍)를 검증된 상태머신으로 대체한다. 머신 정의는 TICKET_WORKFLOW(SSOT)에서 동적으로
5
+ // 빌드하므로 노드/전이를 두 곳에 중복 선언하지 않는다.
6
+ //
7
+ // 모델: actor/런타임 없이 XState v5의 순수 transition()만 사용한다. 에이전트(Claude Code)가
8
+ // 곧 런타임이고, CLI는 stateless다 — 매 호출마다 .md에서 현재 state를 복원해 transition을
9
+ // 한 스텝 계산하고 결과를 .md에 기록한다. 따라서 actor 영속화가 필요 없다.
10
+ //
11
+ // 이벤트 모델: phase 전이는 GO_<target> 이벤트(예: GO_phase2, GO_end)로 표현한다.
12
+ // guard는 전이별 requiredEvidence를 evidence 입력으로 검증한다 — 미충족 시 XState가
13
+ // 전이를 거부하므로 증거 우회 구멍이 구조적으로 막힌다.
14
+
15
+ import { createMachine, transition, initialTransition } from "xstate";
16
+ import { TICKET_WORKFLOW, TICKET_WORKFLOW_EVIDENCE } from "./ticket-workflow.js";
17
+
18
+ export function eventForTarget(targetStateName) {
19
+ return `GO_${targetStateName}`;
20
+ }
21
+
22
+ // TICKET_WORKFLOW.states → XState states 정의. 각 전이는 GO_<target> 이벤트 + guard.
23
+ function buildMachineStates() {
24
+ const states: Record<string, any> = {};
25
+ for (const [name, node] of Object.entries(TICKET_WORKFLOW.states)) {
26
+ const n = node as Record<string, any>;
27
+ const on: Record<string, any> = {};
28
+ for (const target of n.transitions || []) {
29
+ const evidenceKeys = n.requiredEvidence?.[target] || [];
30
+ on[eventForTarget(target)] = {
31
+ target,
32
+ // guard: 이 전이가 요구하는 증거가 evidence 입력에 모두 있어야 통과.
33
+ guard: ({ event }) => {
34
+ const evidence = event?.evidence || {};
35
+ return evidenceKeys.every(key => Boolean(evidence[key]));
36
+ }
37
+ };
38
+ }
39
+ states[name] = (n.transitions || []).length === 0
40
+ ? { type: "final" }
41
+ : { on };
42
+ }
43
+ return states;
44
+ }
45
+
46
+ // 머신은 SSOT(TICKET_WORKFLOW)에서 빌드한다. ESM 순환 import 때문에 모듈 로드 시점엔
47
+ // TICKET_WORKFLOW가 아직 초기화 전일 수 있으므로 lazy로 첫 호출 때 빌드한다. initial은
48
+ // 의미 없으므로(매 호출마다 현재 state를 명시 복원) phase1로 둔다.
49
+ let _machine = null;
50
+ function getMachine() {
51
+ if (!_machine) {
52
+ _machine = createMachine({
53
+ id: "ticket-flow",
54
+ initial: "phase1",
55
+ states: buildMachineStates()
56
+ });
57
+ }
58
+ return _machine;
59
+ }
60
+
61
+ // 현재 state 이름으로 머신 스냅샷을 복원한다(.md phase → state name은 호출측에서 결정).
62
+ function snapshotForState(stateName) {
63
+ const [init] = initialTransition(getMachine());
64
+ // XState v5: 스냅샷의 value를 직접 지정해 임의 state에서 출발시킨다.
65
+ return { ...init, value: stateName };
66
+ }
67
+
68
+ // 전이 결과: { allowed, blocked, reason, to } — 미정의 전이/guard 미충족이면 allowed=false.
69
+ // missingEvidence가 비어있지 않으면 guard 차단(증거 부족), to가 그대로면 미정의 전이.
70
+ export function machineTransition(fromStateName, targetStateName, evidence = {}) {
71
+ if (fromStateName === targetStateName) {
72
+ return { allowed: true, to: targetStateName, missingEvidence: [] };
73
+ }
74
+ const node = TICKET_WORKFLOW.states[fromStateName];
75
+ if (!node) {
76
+ return { allowed: false, reason: "unknown_state", to: fromStateName, missingEvidence: [] };
77
+ }
78
+ const allowedTargets = node.transitions || [];
79
+ if (!allowedTargets.includes(targetStateName)) {
80
+ // 미정의 전이 — 구멍 차단.
81
+ return { allowed: false, reason: "transition_not_declared", to: fromStateName, missingEvidence: [] };
82
+ }
83
+ // 증거 guard 검증 (XState transition으로).
84
+ const snapshot = snapshotForState(fromStateName);
85
+ const [next] = transition(getMachine(), snapshot, {
86
+ type: eventForTarget(targetStateName),
87
+ evidence
88
+ } as any);
89
+ const reached = String(next.value) === targetStateName;
90
+ if (reached) {
91
+ return { allowed: true, to: targetStateName, missingEvidence: [] };
92
+ }
93
+ // guard가 막음 — 어떤 증거가 비었는지 산출.
94
+ const evidenceKeys = node.requiredEvidence?.[targetStateName] || [];
95
+ const missingEvidence = evidenceKeys
96
+ .filter(key => !evidence[key])
97
+ .map(key => TICKET_WORKFLOW_EVIDENCE[key]?.missingReason || `${key}_missing`);
98
+ return { allowed: false, reason: "guard_blocked", to: fromStateName, missingEvidence };
99
+ }
@@ -0,0 +1,544 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { dirname, resolve, relative } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { CliOpts, DEUK_ROOT_DIR, makePath, resolveConsumerTicketRoot, resolvePackageRoot, toFileUri, toPosixPath } from "./cli-utils.js";
5
+ // #691: 전이/guard 검증은 XState 머신(ticket-machine.mjs)이 담당. ESM 순환 import이지만
6
+ // machineTransition은 호출 시점에만 TICKET_WORKFLOW를 읽으므로 안전하다.
7
+ import { machineTransition } from "./ticket-machine.js";
8
+
9
+ const PACKAGE_ROOT = resolvePackageRoot({ fromUrl: import.meta.url });
10
+
11
+ const TERMINAL_TICKET_STATUSES = new Set(["closed", "completed", "cancelled", "wontfix", "deprecated", "archived"]);
12
+
13
+ // "active" is no longer a status value — a ticket's focus is expressed solely by
14
+ // activeTicketId. status carries lifecycle only: open / archived / closed.
15
+ // Kept "active" here for backward-compat reads of legacy ticket files; new writes use "open".
16
+ export const OPEN_TICKET_STATUSES = new Set(["open", "active"]);
17
+ export const AUTO_ARCHIVE_DONE_STATUSES = new Set(["closed", "completed", "cancelled", "wontfix"]);
18
+
19
+ export function isTerminalTicketStatus(status) {
20
+ return TERMINAL_TICKET_STATUSES.has(String(status || "").toLowerCase());
21
+ }
22
+
23
+ export const FLOW_SURFACE_STATUS = {
24
+ start: "🌱 시작",
25
+ status: "🔍 조회",
26
+ adjust: "📝 조정",
27
+ progressActive: "⚙️ 진행",
28
+ progressComplete: "✅ 완료",
29
+ end: "🎉 종료"
30
+ };
31
+
32
+ export const TICKET_WORKFLOW = {
33
+ name: "ticket_workflow",
34
+ promptRoot: makePath("docs", "cli-surfaces", "state"),
35
+ templateRoot: makePath("docs", "cli-surfaces", "state-template"),
36
+ policyRoot: makePath(DEUK_ROOT_DIR, "project-guardrails"),
37
+ states: {
38
+ // #690: entry 노드 — rules가 진입 시 라우팅하는 그래프의 시작점들. 프롬프트는
39
+ // docs/cli-surfaces/state/entry-*.md(SSOT). 티켓 phase 노드(phase1~4)와 함께
40
+ // 단일 노드 그래프를 이룬다. router가 cwd/.md 상태로 이 중 하나를 고른다.
41
+ "entry-no-workspace": { prompt: "entry-no-workspace", entry: true, transitions: [], actions: [] },
42
+ "entry-free": { prompt: "entry-free", entry: true, transitions: [], actions: [] },
43
+ "entry-no-active": { prompt: "entry-no-active", entry: true, transitions: ["phase1"], actions: ["create"] },
44
+ "entry-in-progress": { prompt: "entry-in-progress", entry: true, transitions: [], actions: ["use", "move"] },
45
+ start: { prompt: "start", policyRole: "analysis", template: "start", phase: 1, status: "open", transitions: ["phase1", "end"], actions: ["create"] },
46
+ phase1: {
47
+ prompt: "phase1",
48
+ policyRole: "analysis",
49
+ template: "phase1",
50
+ phase: 1,
51
+ status: "open",
52
+ transitions: ["phase2", "end"],
53
+ actions: ["status", "use", "guard", "move", "discard"],
54
+ requiredEvidence: {
55
+ phase2: ["phase1Plan", "userApproval"],
56
+ end: ["phase1Plan"]
57
+ }
58
+ },
59
+ phase2: {
60
+ prompt: "phase2",
61
+ policyRole: "coding",
62
+ template: "phase2",
63
+ phase: 2,
64
+ status: "open",
65
+ transitions: ["phase3", "end"],
66
+ actions: ["status", "use", "move", "close", "reportAttach"],
67
+ requiredEvidence: {
68
+ phase3: ["executionEvidence"],
69
+ end: ["completionEvidence"]
70
+ }
71
+ },
72
+ phase3: {
73
+ prompt: "phase3",
74
+ policyRole: "debugging",
75
+ template: "phase3",
76
+ phase: 3,
77
+ status: "open",
78
+ transitions: ["phase4", "phase2", "end"],
79
+ actions: ["status", "use", "move", "close", "reportAttach"],
80
+ requiredEvidence: {
81
+ phase4: ["verificationEvidence"],
82
+ phase2: ["debuggingDecision"],
83
+ end: ["completionEvidence"]
84
+ }
85
+ },
86
+ phase4: {
87
+ prompt: "phase4",
88
+ policyRole: "completion",
89
+ template: "phase4",
90
+ phase: 4,
91
+ status: "closed",
92
+ transitions: ["end", "phase1"],
93
+ actions: ["status", "use", "move", "archive", "reportAttach"],
94
+ requiredEvidence: {
95
+ end: ["completionEvidence"],
96
+ phase1: ["reopenDecision"]
97
+ }
98
+ },
99
+ end: { prompt: "end", policyRole: "completion", template: "end", phase: 4, status: "closed", transitions: [], actions: ["status", "use", "archive"] },
100
+ useCoreActions: { prompt: "use-core-actions", transitions: [], actions: [] }
101
+ }
102
+ };
103
+
104
+ export const TICKET_WORKFLOW_EVIDENCE = {
105
+ phase1Plan: {
106
+ label: "phase1_plan_complete",
107
+ missingReason: "phase1_plan_incomplete"
108
+ },
109
+ userApproval: {
110
+ label: "user_approval",
111
+ missingReason: "user_approval_missing"
112
+ },
113
+ executionEvidence: {
114
+ label: "execution_evidence",
115
+ missingReason: "execution_evidence_missing"
116
+ },
117
+ verificationEvidence: {
118
+ label: "verification_evidence",
119
+ missingReason: "verification_evidence_missing"
120
+ },
121
+ debuggingDecision: {
122
+ label: "debugging_decision",
123
+ missingReason: "debugging_decision_missing"
124
+ },
125
+ completionEvidence: {
126
+ label: "completion_evidence",
127
+ missingReason: "completion_evidence_missing"
128
+ },
129
+ reopenDecision: {
130
+ label: "reopen_decision",
131
+ missingReason: "reopen_decision_missing"
132
+ }
133
+ };
134
+
135
+ function resolveGuideLanguage(docsLanguage = "ko", resolveLanguage = (value) => value, normalizeDocsLanguage = (value) => value) {
136
+ const normalizedDocsLanguage = normalizeDocsLanguage(docsLanguage);
137
+ return normalizedDocsLanguage === "auto" ? "ko" : resolveLanguage(normalizedDocsLanguage);
138
+ }
139
+
140
+ function resolveTicketWorkflowState(kind) {
141
+ const state = TICKET_WORKFLOW.states[kind];
142
+ if (!state) {
143
+ throw new Error(`ticket workflow state not declared: ${kind}`);
144
+ }
145
+ return state;
146
+ }
147
+
148
+ function ticketWorkflowStateNameFromPhase(phase) {
149
+ const normalizedPhase = normalizeTicketPhaseNumber(phase);
150
+ if (normalizedPhase >= 4) return "phase4";
151
+ return `phase${normalizedPhase}`;
152
+ }
153
+
154
+ function ticketWorkflowStateNameFromMeta(meta: Record<string, any> = {}, fallbackStatus = "open") {
155
+ const status = deriveTicketWorkflowStatus(meta, fallbackStatus);
156
+ if (TERMINAL_TICKET_STATUSES.has(status)) return "phase4";
157
+ return ticketWorkflowStateNameFromPhase(meta.phase || 1);
158
+ }
159
+
160
+ function statusForTicketWorkflowState(targetStateName, currentStatus = "open") {
161
+ const targetState = resolveTicketWorkflowState(targetStateName);
162
+ const targetPhase = normalizeTicketPhaseNumber(targetState.phase || 1);
163
+ if (targetState.status) return targetState.status;
164
+ if (targetPhase >= 4) return "closed";
165
+ if (targetPhase >= 2) {
166
+ // Execution phases keep status "open"; focus is tracked via activeTicketId.
167
+ const normalizedCurrentStatus = String(currentStatus || "open").toLowerCase();
168
+ return normalizedCurrentStatus === "active" ? "open" : normalizedCurrentStatus;
169
+ }
170
+ return "open";
171
+ }
172
+
173
+ export function resolveTicketWorkflowTransition(meta: Record<string, any> = {}, opts: CliOpts = {}) {
174
+ const currentState = opts.fromState || ticketWorkflowStateNameFromMeta(meta, opts.fallbackStatus);
175
+ const currentWorkflowState = resolveTicketWorkflowState(currentState);
176
+ const targetState = opts.toState
177
+ || (opts.phase ? ticketWorkflowStateNameFromPhase(opts.phase) : null)
178
+ || (opts.next ? currentWorkflowState.transitions?.[0] : null);
179
+ if (!targetState) {
180
+ throw new Error(`ticket workflow transition target is required from ${currentState}`);
181
+ }
182
+ const targetWorkflowState = resolveTicketWorkflowState(targetState);
183
+ // #691: 전이 허용 검증은 XState 머신이 담당한다(미정의 전이 차단). 증거 guard는
184
+ // getTicketWorkflowTransitionReasons에서 evidence와 함께 별도로 검증한다.
185
+ const verdict = machineTransition(currentState, targetState);
186
+ if (verdict.reason === "transition_not_declared") {
187
+ throw new Error(`ticket workflow transition blocked: ${currentState} -> ${targetState}`);
188
+ }
189
+ const phase = normalizeTicketPhaseNumber(targetWorkflowState.phase || meta.phase || 1);
190
+ const status = statusForTicketWorkflowState(targetState, meta.status || opts.fallbackStatus);
191
+ return {
192
+ workflow: TICKET_WORKFLOW.name,
193
+ from: currentState,
194
+ to: targetState,
195
+ phase,
196
+ status,
197
+ state: targetWorkflowState
198
+ };
199
+ }
200
+
201
+ export function getTicketWorkflowTransitionEvidenceKeys(fromStateName, toStateName) {
202
+ const fromState = resolveTicketWorkflowState(fromStateName);
203
+ return fromState.requiredEvidence?.[toStateName] || [];
204
+ }
205
+
206
+ export function resolveTicketWorkflowRuntimeState(meta: Record<string, any> = {}, fallbackStatus = "open") {
207
+ const stateName = ticketWorkflowStateNameFromMeta(meta, fallbackStatus);
208
+ return {
209
+ name: stateName,
210
+ state: resolveTicketWorkflowState(stateName)
211
+ };
212
+ }
213
+
214
+ export function getTicketWorkflowActionReasons(meta: Record<string, any> = {}, opts: CliOpts = {}) {
215
+ const action = String(opts.action || "").trim();
216
+ const { name, state } = resolveTicketWorkflowRuntimeState(meta, opts.fallbackStatus);
217
+ if (!action) return [`ticket_workflow_action_missing:${name}`];
218
+ const allowedActions = state.actions || [];
219
+ if (!allowedActions.includes(action)) {
220
+ return [`ticket_workflow_action_blocked:${name}:${action}`];
221
+ }
222
+ return [];
223
+ }
224
+
225
+ export function assertTicketWorkflowAction(meta: Record<string, any> = {}, opts: CliOpts = {}) {
226
+ const reasons = getTicketWorkflowActionReasons(meta, opts);
227
+ if (reasons.length === 0) return;
228
+ const { name, state } = resolveTicketWorkflowRuntimeState(meta, opts.fallbackStatus);
229
+ const allowedActions = (state.actions || []).join(", ") || "none";
230
+ const allowedTransitions = (state.transitions || []).join(", ") || "none";
231
+ const canDiscard = (state.actions || []).includes("discard");
232
+ const discardHint = canDiscard
233
+ ? `\nTo abandon this ticket: ticket discard --id <id> --workspace <ws> --non-interactive`
234
+ : "";
235
+ throw new Error(
236
+ `[TICKET WORKFLOW BLOCKED] ${reasons.join(", ")}\n` +
237
+ `Current state: ${name} | Allowed actions: ${allowedActions} | Valid transitions: ${allowedTransitions}${discardHint}`
238
+ );
239
+ }
240
+
241
+ export function getTicketWorkflowTransitionReasons(meta: Record<string, any> = {}, opts: CliOpts = {}) {
242
+ const transition = resolveTicketWorkflowTransition(meta, opts);
243
+ const evidence = opts.evidence || {};
244
+ // #691: 증거 guard는 XState 머신이 검증한다. machineTransition이 guard 미충족 시
245
+ // missingEvidence를 산출하므로, 흩어진 검증 대신 머신을 단일 진실로 쓴다.
246
+ const verdict = machineTransition(transition.from, transition.to, evidence);
247
+ return {
248
+ transition,
249
+ reasons: verdict.missingEvidence || []
250
+ };
251
+ }
252
+
253
+ export function assertTicketWorkflowTransition(meta: Record<string, any> = {}, opts: CliOpts = {}) {
254
+ const result = getTicketWorkflowTransitionReasons(meta, opts);
255
+ if (result.reasons.length > 0) {
256
+ const fromState = TICKET_WORKFLOW.states[result.transition.from];
257
+ const validTransitions = (fromState?.transitions || []).join(", ") || "none";
258
+ throw new Error(
259
+ `[TICKET WORKFLOW BLOCKED] ${result.transition.from} -> ${result.transition.to}: ${result.reasons.join(", ")}\n` +
260
+ `Valid transitions from ${result.transition.from}: ${validTransitions}`
261
+ );
262
+ }
263
+ return result.transition;
264
+ }
265
+
266
+ function readProjectPolicyText(cwd, policyRole, language, replacements: Record<string, any> = {}) {
267
+ if (!cwd || !policyRole) return "";
268
+ const localizedPolicyPath = makePath(cwd, DEUK_ROOT_DIR, "project-guardrails", `${policyRole}.${language}.md`);
269
+ const policyPath = existsSync(localizedPolicyPath)
270
+ ? localizedPolicyPath
271
+ : makePath(cwd, DEUK_ROOT_DIR, "project-guardrails", `${policyRole}.md`);
272
+ if (!existsSync(policyPath)) return "";
273
+ let content = readFileSync(policyPath, "utf8");
274
+ const projectRulePath = makePath(cwd, DEUK_ROOT_DIR, "PROJECT_RULE.md");
275
+ const allReplacements = {
276
+ PROJECT_RULE_PATH: projectRulePath,
277
+ PROJECT_GUARDRAIL: policyRole,
278
+ PROJECT_POLICY_ROLE: policyRole,
279
+ ...replacements
280
+ };
281
+ for (const [key, value] of Object.entries(allReplacements)) {
282
+ content = content.replaceAll(`{{${key}}}`, String(value ?? ""));
283
+ }
284
+ return content.trim();
285
+ }
286
+
287
+ function readLocalizedPackagePrompt(rootName, promptName, language, replacements: Record<string, any> = {}, missingLabel = "ticket workflow recipe") {
288
+ if (!promptName) return "";
289
+ const localizedPath = makePath(PACKAGE_ROOT, "docs", "cli-surfaces", rootName, `${promptName}.${language}.md`);
290
+ const fallbackPath = makePath(PACKAGE_ROOT, "docs", "cli-surfaces", rootName, `${promptName}.en.md`);
291
+ const promptPath = existsSync(localizedPath) ? localizedPath : fallbackPath;
292
+ if (!existsSync(promptPath)) {
293
+ throw new Error(`${missingLabel} not found: ${promptPath}`);
294
+ }
295
+ let content = readFileSync(promptPath, "utf8");
296
+ for (const [key, value] of Object.entries(replacements)) {
297
+ content = content.replaceAll(`{{${key}}}`, String(value ?? ""));
298
+ }
299
+ return content.trimEnd();
300
+ }
301
+
302
+ function buildTicketStateRecipe(kind, language, replacements: Record<string, any> = {}, context: Record<string, any> = {}) {
303
+ const workflowState = resolveTicketWorkflowState(kind);
304
+ const statePrompt = readLocalizedPackagePrompt(
305
+ "state",
306
+ workflowState.prompt,
307
+ language,
308
+ replacements,
309
+ "ticket workflow state recipe"
310
+ );
311
+ const projectPolicy = readProjectPolicyText(
312
+ context.cwd,
313
+ context.policyRole || workflowState.policyRole,
314
+ language,
315
+ replacements
316
+ );
317
+ const stateTemplate = readLocalizedPackagePrompt(
318
+ "state-template",
319
+ context.template || workflowState.template,
320
+ language,
321
+ replacements,
322
+ "ticket workflow state template"
323
+ );
324
+ const runtimeContext = renderTicketWorkflowRuntimeContext(kind, workflowState, context.runtimeContext);
325
+ return {
326
+ state_prompt: statePrompt,
327
+ state_template: stateTemplate,
328
+ runtime_context: runtimeContext,
329
+ policy_metadata: projectPolicy,
330
+ compact_recipe: [statePrompt, stateTemplate].filter(Boolean).join("\n\n---\n\n"),
331
+ full_recipe: [statePrompt, runtimeContext, projectPolicy, stateTemplate].filter(Boolean).join("\n\n---\n\n")
332
+ };
333
+ }
334
+
335
+ function renderTicketWorkflowRuntimeContext(kind, workflowState, runtimeContext = null) {
336
+ if (!runtimeContext || Object.keys(runtimeContext).length === 0) return "";
337
+ const transitions = workflowState.transitions || [];
338
+ const actions = workflowState.actions || [];
339
+ const requiredEvidence = workflowState.requiredEvidence || {};
340
+ const runtimeLines = [
341
+ "## Ticket Workflow Runtime Context",
342
+ `- State: ${kind}`,
343
+ `- Ticket: ${runtimeContext.ticketId || ""}`,
344
+ `- Host Workspace: ${runtimeContext.hostWorkspaceLabel || runtimeContext.workspaceLabel || ""}`,
345
+ `- Workspace: ${runtimeContext.workspaceLabel || ""}`,
346
+ `- Path: ${runtimeContext.path || ""}`,
347
+ `- Phase/Status: ${runtimeContext.phase || ""}/${runtimeContext.status || ""}`,
348
+ `- Workflow Status: ${runtimeContext.status || ""}`,
349
+ `- Summary: ${runtimeContext.summary || ""}`,
350
+ `- Connection State: ${runtimeContext.connectionState || "unknown"}`,
351
+ `- Allowed actions: ${actions.length ? actions.join(", ") : "none"}`,
352
+ `- Allowed transitions: ${transitions.length ? transitions.join(", ") : "terminal"}`,
353
+ `- Required evidence: ${Object.entries(requiredEvidence).map(([target, evidence]) => `${target}=[${(evidence as string[]).join(", ")}]`).join("; ") || "none"}`,
354
+ `- Current blockers: ${(runtimeContext.reasons || []).length ? runtimeContext.reasons.join(", ") : "none"}`
355
+ ];
356
+ if (runtimeContext.nextAction) runtimeLines.push(`- Next action: ${runtimeContext.nextAction}`);
357
+ return runtimeLines.join("\n");
358
+ }
359
+
360
+ function normalizeTicketWorkflowReplacements(replacements: Record<string, any> = {}) {
361
+ return Object.fromEntries(
362
+ Object.entries(replacements).map(([key, value]) => [key, value == null ? "" : value])
363
+ );
364
+ }
365
+
366
+ export function buildTicketDocMetaSurface(kind, opts: CliOpts = {}) {
367
+ const language = resolveGuideLanguage(
368
+ opts.docsLanguage,
369
+ opts.resolveLanguage || ((value) => value),
370
+ opts.normalizeDocsLanguage || ((value) => value)
371
+ );
372
+ const replacements = normalizeTicketWorkflowReplacements(opts.replacements);
373
+ const context = {
374
+ cwd: opts.cwd,
375
+ policyRole: opts.policyRole,
376
+ template: opts.template,
377
+ runtimeContext: opts.runtimeContext,
378
+ surfaceMode: opts.surfaceMode
379
+ };
380
+ const workflowState = resolveTicketWorkflowState(kind);
381
+ const recipe = buildTicketStateRecipe(kind, language, replacements, context);
382
+ const requiredEvidence = workflowState.requiredEvidence || {};
383
+ const runtimeContext = opts.runtimeContext || {};
384
+ const currentPhase = runtimeContext.phase || workflowState.phase || "";
385
+ const currentStatus = runtimeContext.status || workflowState.status || "";
386
+ // Dedupe: a slot (e.g. phase1Plan) can be required by more than one outgoing
387
+ // transition (phase2 and end), and flattening would list it twice.
388
+ const requiredSlots = [...new Set(Object.values(requiredEvidence).flat())];
389
+ const outputStatus = requiredSlots.length ? "action_requires_evidence" : "action_available";
390
+ return {
391
+ document_type: "agent_flow_surface",
392
+ document_subtype: "ticket_workflow_state",
393
+ contract_version: "documeta-0.1",
394
+ workflow: TICKET_WORKFLOW.name,
395
+ state: {
396
+ name: kind,
397
+ phase: currentPhase,
398
+ status: currentStatus,
399
+ actions: workflowState.actions || [],
400
+ transitions: workflowState.transitions || []
401
+ },
402
+ language,
403
+ recipe: {
404
+ prompt_id: workflowState.prompt || "",
405
+ template_id: context.template || workflowState.template || "",
406
+ text: recipe.compact_recipe
407
+ },
408
+ policy_metadata: {
409
+ role: context.policyRole || workflowState.policyRole || "",
410
+ text: recipe.policy_metadata
411
+ },
412
+ runtime_context: runtimeContext,
413
+ source_contract: {
414
+ required_slots: requiredSlots,
415
+ metadata_fields: ["ticket_id", "workspace", "phase", "status", "path"]
416
+ },
417
+ validation: {
418
+ status: outputStatus === "action_available" ? "PASS" : "READY",
419
+ errors: []
420
+ },
421
+ output_status: outputStatus,
422
+ debug: {
423
+ runtime_context_text: recipe.runtime_context,
424
+ full_recipe: recipe.full_recipe
425
+ }
426
+ };
427
+ }
428
+
429
+ export function renderTicketDocMetaSurface(kind, opts: CliOpts = {}) {
430
+ const docmeta = buildTicketDocMetaSurface(kind, opts);
431
+ if (opts.json) {
432
+ return JSON.stringify(docmeta, null, 2);
433
+ }
434
+ if (opts.surfaceMode === "full" || opts.verbose) {
435
+ return [
436
+ `DocMeta state: ${docmeta.state.name}`,
437
+ docmeta.debug.full_recipe
438
+ ].filter(Boolean).join("\n\n---\n\n");
439
+ }
440
+ const runtime = docmeta.runtime_context || {};
441
+ const lines = [
442
+ `DocMeta state: ${docmeta.state.name}`,
443
+ runtime.ticketId ? `Ticket: ${runtime.ticketId}` : "",
444
+ runtime.summary ? `Summary: ${runtime.summary}` : "",
445
+ `Phase/Status: ${docmeta.state.phase || ""}/${docmeta.state.status || ""}`,
446
+ docmeta.source_contract.required_slots.length
447
+ ? `Required slots: ${docmeta.source_contract.required_slots.join(", ")}`
448
+ : "Required slots: none",
449
+ runtime.nextAction ? `Next action: ${runtime.nextAction}` : "",
450
+ `Output: ${docmeta.output_status}`
451
+ ];
452
+ return lines.filter(Boolean).join("\n");
453
+ }
454
+
455
+ export function buildTicketFlowSurface(kind, opts: CliOpts = {}) {
456
+ const docmeta = buildTicketDocMetaSurface(kind, opts);
457
+ const flowLine = opts.flow
458
+ ? formatTicketFlowLine(opts.flow.workspaceLabel, opts.flow.statusLabel, opts.flow.ticketId, opts.flow.absPath, opts.flow.invocationCwd || opts.cwd || "")
459
+ : "";
460
+ const surfaceText = renderTicketDocMetaSurface(kind, opts);
461
+ return {
462
+ ...docmeta,
463
+ flowLine,
464
+ text: flowLine
465
+ };
466
+ }
467
+
468
+ export function renderTicketFlowSurface(kind, opts: CliOpts = {}) {
469
+ return buildTicketFlowSurface(kind, opts).text;
470
+ }
471
+
472
+ export function normalizeTicketPhaseNumber(phase) {
473
+ const parsed = Number(phase);
474
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1;
475
+ }
476
+
477
+ export function sanitizeTicketWorkspaceLabel(workspaceLabel) {
478
+ const normalized = String(workspaceLabel || "").trim();
479
+ if (!normalized) return "";
480
+ if (/^(?:workspace|none|null|undefined)$/i.test(normalized)) return "";
481
+ return normalized;
482
+ }
483
+
484
+ export function formatTicketFlowLine(workspaceLabel, statusLabel, ticketId, absPath, cwd = "") {
485
+ const resolvedWorkspaceLabel = sanitizeTicketWorkspaceLabel(workspaceLabel);
486
+ if (!resolvedWorkspaceLabel) {
487
+ throw new Error("ticket flow surface requires a concrete workspace label");
488
+ }
489
+ const resolvedStatusLabel = String(statusLabel || "").trim();
490
+ if (!resolvedStatusLabel) {
491
+ throw new Error("ticket flow surface requires a concrete status label");
492
+ }
493
+ const absResolved = resolve(absPath);
494
+ // #646: tickets now live in the home store (~/.deuk-agent/tickets/{uuid}/),
495
+ // a different drive/root than the workspace cwd — relative(cwd, abs) leaks an
496
+ // unopenable path. Always emit a makePath-normalized absolute path so the
497
+ // review link opens from anywhere.
498
+ // #647: emit a POSIX absolute path WITHOUT the Windows drive letter
499
+ // ("C:/Users/..." → "/Users/..."). The Claude Code chat-panel click handler
500
+ // breaks on the drive-letter colon (anthropics/claude-code#53919), so a
501
+ // drive-prefixed link never opens. Stripping "C:" yields a slash-rooted path
502
+ // the chat UI opens, while the VS Code webview's vscode.open resolves it too.
503
+ const linkPath = toPosixPath(makePath(absResolved)).replace(/^[a-zA-Z]:/, "");
504
+ // Parse ticketId: "028-some-title-joy-deep" → num="028", slug="some-title-joy-deep"
505
+ const idMatch = String(ticketId).match(/^(\d+)-(.+)$/);
506
+ const num = idMatch ? idMatch[1] : "";
507
+ const rawSlug = idMatch ? idMatch[2] : ticketId;
508
+ const slug = rawSlug.length > 40 ? rawSlug.slice(0, 40) : rawSlug;
509
+ const label = num ? `${num} ${slug}` : slug;
510
+ return `📦 ${resolvedWorkspaceLabel} · ${resolvedStatusLabel} · [${label}](${linkPath})`;
511
+ }
512
+
513
+ export function getTicketGuide(kind, docsLanguage = "ko", resolveLanguage = (value) => value, normalizeDocsLanguage = (value) => value, context: Record<string, any> = {}) {
514
+ return renderTicketDocMetaSurface(kind, {
515
+ docsLanguage,
516
+ resolveLanguage,
517
+ normalizeDocsLanguage,
518
+ cwd: context.cwd,
519
+ policyRole: context.policyRole,
520
+ template: context.template
521
+ });
522
+ }
523
+
524
+ export function deriveTicketWorkflowStatus(meta: Record<string, any> = {}, fallbackStatus = "open") {
525
+ const rawStatus = String(meta.status || fallbackStatus || "open").toLowerCase();
526
+ const phase = normalizeTicketPhaseNumber(meta.phase || 1);
527
+ if (TERMINAL_TICKET_STATUSES.has(rawStatus)) {
528
+ return rawStatus;
529
+ }
530
+ if (phase >= 4) return "closed";
531
+ // phase>=2 no longer implies "active": status stays "open" through execution.
532
+ // Focus is tracked by activeTicketId, not by deriving active from phase.
533
+ if (phase >= 2) return rawStatus === "active" ? "open" : rawStatus;
534
+ return rawStatus || "open";
535
+ }
536
+
537
+ export function isExecutionTicketStatus(status) {
538
+ return OPEN_TICKET_STATUSES.has(String(status || "open").toLowerCase());
539
+ }
540
+
541
+ export function inferTicketWorkflowStatus(meta: Record<string, any> = {}, isArchived = false) {
542
+ if (isArchived) return "archived";
543
+ return deriveTicketWorkflowStatus(meta, "open");
544
+ }
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: role-closer
3
+ summary: '🏁 phase4 정리자 역할 레이어 — 완료 근거·잔여 리스크 명시, 문서 정본화, 새 작업 자작 금지.'
4
+ category: role
5
+ bind: phase4
6
+ system: true
7
+ ---
8
+
9
+ # Role Layer — Closer
10
+
11
+ Authority: core-rules/AGENTS.md > active ticket APC > this role layer > tone(persona-maid).
12
+ 이 레이어는 말투를 바꾸지 않는다(메이드 톤 유지). 이 페이즈에서 무엇을 하고 무엇을 하면 안 되는지를 정한다.
13
+ 페이즈를 벗어나는 행동(스코프 밖 새 작업, APC 밖 파일 수정)은 위반 — 멈추고 사용자에게 알린다.
14
+
15
+ LEVEL: 시니어. 완료를 가볍게 선언하지 않는다. "정말 끝났는가, 무엇이 남았는가"를 마지막으로 묻는다.
16
+
17
+ 너는 지금 **정리자**다 — 완료 선언 전 마지막 점검.
18
+
19
+ ## MUST
20
+ - 완료 근거(무엇이 검증으로 확인됐는지)와 잔여 리스크를 명시한다.
21
+ - 문서/룰이 코드와 어긋났으면 정본화한다(doc-sync 연계).
22
+
23
+ ## MUST NOT
24
+ - 새 작업을 자작하지 않는다(Runaway Prevention 본래 의도). 티켓 스코프 밖은 새 티켓으로.
25
+
26
+ ## STOP
27
+ - 티켓 close → 간결한 완료 보고 후 멈춘다.