deuk-agent-flow 5.0.2 → 5.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.ko.md +18 -0
  2. package/CHANGELOG.md +25 -0
  3. package/bundled/deuk-agent-flow.vsix +0 -0
  4. package/core-rules/AGENTS.md +5 -5
  5. package/package.json +20 -2
  6. package/scripts/bundle-vscode-vsix.ts +1 -1
  7. package/scripts/cli-context-commands.ts +35 -0
  8. package/scripts/cli-init-claude.ts +179 -0
  9. package/scripts/cli-init-core.ts +89 -0
  10. package/scripts/cli-init-instructions.ts +489 -0
  11. package/scripts/cli-init-migrate.ts +504 -0
  12. package/scripts/cli-init-workspace.ts +63 -0
  13. package/scripts/cli-locales.ts +85 -0
  14. package/scripts/cli-skill-commands.ts +3 -1
  15. package/scripts/cli-ticket-commands.ts +58 -0
  16. package/scripts/cli-ticket-docmeta.ts +103 -0
  17. package/scripts/cli-ticket-document.ts +153 -0
  18. package/scripts/cli-ticket-evidence.ts +312 -0
  19. package/scripts/cli-ticket-home.ts +449 -0
  20. package/scripts/cli-ticket-scan.ts +102 -0
  21. package/scripts/cli-ticket-surface.ts +56 -0
  22. package/scripts/oss-prepack.js +32 -0
  23. package/scripts/out/scripts/bundle-vscode-vsix.js +68 -0
  24. package/scripts/out/scripts/bundle-vscode-vsix.js.map +1 -0
  25. package/scripts/out/scripts/cli-args.js +374 -0
  26. package/scripts/out/scripts/cli-args.js.map +1 -0
  27. package/scripts/out/scripts/cli-context-commands.js +31 -0
  28. package/scripts/out/scripts/cli-context-commands.js.map +1 -0
  29. package/scripts/out/scripts/cli-init-claude.js +176 -0
  30. package/scripts/out/scripts/cli-init-claude.js.map +1 -0
  31. package/scripts/out/scripts/cli-init-commands.js +87 -0
  32. package/scripts/out/scripts/cli-init-commands.js.map +1 -0
  33. package/scripts/out/scripts/cli-init-core.js +89 -0
  34. package/scripts/out/scripts/cli-init-core.js.map +1 -0
  35. package/scripts/out/scripts/cli-init-instructions.js +460 -0
  36. package/scripts/out/scripts/cli-init-instructions.js.map +1 -0
  37. package/scripts/out/scripts/cli-init-logic.js +41 -0
  38. package/scripts/out/scripts/cli-init-logic.js.map +1 -0
  39. package/scripts/out/scripts/cli-init-migrate.js +497 -0
  40. package/scripts/out/scripts/cli-init-migrate.js.map +1 -0
  41. package/scripts/out/scripts/cli-init-workspace.js +57 -0
  42. package/scripts/out/scripts/cli-init-workspace.js.map +1 -0
  43. package/scripts/out/scripts/cli-locales.js +83 -0
  44. package/scripts/out/scripts/cli-locales.js.map +1 -0
  45. package/scripts/out/scripts/cli-prompts.js +101 -0
  46. package/scripts/out/scripts/cli-prompts.js.map +1 -0
  47. package/scripts/out/scripts/cli-rule-compiler.js +121 -0
  48. package/scripts/out/scripts/cli-rule-compiler.js.map +1 -0
  49. package/scripts/out/scripts/cli-skill-commands.js +708 -0
  50. package/scripts/out/scripts/cli-skill-commands.js.map +1 -0
  51. package/scripts/out/scripts/cli-telemetry-commands.js +604 -0
  52. package/scripts/out/scripts/cli-telemetry-commands.js.map +1 -0
  53. package/scripts/out/scripts/cli-ticket-command-shared.js +2 -0
  54. package/scripts/out/scripts/cli-ticket-command-shared.js.map +1 -0
  55. package/scripts/out/scripts/cli-ticket-commands.js +3530 -0
  56. package/scripts/out/scripts/cli-ticket-commands.js.map +1 -0
  57. package/scripts/out/scripts/cli-ticket-docmeta.js +90 -0
  58. package/scripts/out/scripts/cli-ticket-docmeta.js.map +1 -0
  59. package/scripts/out/scripts/cli-ticket-document.js +121 -0
  60. package/scripts/out/scripts/cli-ticket-document.js.map +1 -0
  61. package/scripts/out/scripts/cli-ticket-evidence.js +286 -0
  62. package/scripts/out/scripts/cli-ticket-evidence.js.map +1 -0
  63. package/scripts/out/scripts/cli-ticket-home.js +440 -0
  64. package/scripts/out/scripts/cli-ticket-home.js.map +1 -0
  65. package/scripts/out/scripts/cli-ticket-index.js +272 -0
  66. package/scripts/out/scripts/cli-ticket-index.js.map +1 -0
  67. package/scripts/out/scripts/cli-ticket-migration.js +261 -0
  68. package/scripts/out/scripts/cli-ticket-migration.js.map +1 -0
  69. package/scripts/out/scripts/cli-ticket-parser.js +90 -0
  70. package/scripts/out/scripts/cli-ticket-parser.js.map +1 -0
  71. package/scripts/out/scripts/cli-ticket-scan.js +93 -0
  72. package/scripts/out/scripts/cli-ticket-scan.js.map +1 -0
  73. package/scripts/out/scripts/cli-ticket-surface.js +53 -0
  74. package/scripts/out/scripts/cli-ticket-surface.js.map +1 -0
  75. package/scripts/out/scripts/cli-usage-commands.js +310 -0
  76. package/scripts/out/scripts/cli-usage-commands.js.map +1 -0
  77. package/scripts/out/scripts/cli-utils.js +1544 -0
  78. package/scripts/out/scripts/cli-utils.js.map +1 -0
  79. package/scripts/out/scripts/cli.js +696 -0
  80. package/scripts/out/scripts/cli.js.map +1 -0
  81. package/scripts/out/scripts/lint-md.js +238 -0
  82. package/scripts/out/scripts/lint-md.js.map +1 -0
  83. package/scripts/out/scripts/lint-rules.js +193 -0
  84. package/scripts/out/scripts/lint-rules.js.map +1 -0
  85. package/scripts/out/scripts/merge-logic.js +38 -0
  86. package/scripts/out/scripts/merge-logic.js.map +1 -0
  87. package/scripts/out/scripts/plan-parser.js +52 -0
  88. package/scripts/out/scripts/plan-parser.js.map +1 -0
  89. package/scripts/out/scripts/ticket-machine.js +94 -0
  90. package/scripts/out/scripts/ticket-machine.js.map +1 -0
  91. package/scripts/out/scripts/ticket-workflow.js +498 -0
  92. package/scripts/out/scripts/ticket-workflow.js.map +1 -0
  93. package/scripts/ticket-machine.ts +99 -0
  94. package/scripts/ticket-workflow.ts +544 -0
  95. package/templates/skills/role-closer/SKILL.md +27 -0
  96. package/templates/skills/role-implementer/SKILL.md +30 -0
  97. package/templates/skills/role-planner/SKILL.md +29 -0
  98. package/templates/skills/role-qa-verifier/SKILL.md +30 -0
@@ -423,6 +423,56 @@ export function buildTicketCreateGuide(opts: CliOpts = {}) {
423
423
  });
424
424
  }
425
425
 
426
+ // 티켓의 tags/summary에서 작업 도메인을 뽑아 역할 프롬프트에 주입할 한 줄 지시로 변환.
427
+ // 매칭 규칙은 작게 시작해 확장한다(설계 §4.2). 매칭 없으면 빈 문자열(역할 기본 문안만).
428
+ const DOMAIN_FOCUS_RULES: Array<{ keys: string[]; focus: string }> = [
429
+ { keys: ["codegen", "idl", "generate", "생성물"], focus: "[DOMAIN_FOCUS: codegen] 코드 생성물 관련. 생성물(generated/·idl)을 직접 수정하지 말고 소스→재생성 경로로만. 빌드타임 타입 확정을 런타임 registry/convention 우회로 대체 금지." },
430
+ { keys: ["refactor", "리팩터", "리팩토링"], focus: "[DOMAIN_FOCUS: refactor] 리팩터. 동작 보존이 최우선 — 변경 전후 동치성을 검증으로 보이고, 스코프를 최소·국소로 유지." },
431
+ { keys: ["ui", "webview", "widget", "css", "레이아웃", "디자인"], focus: "[DOMAIN_FOCUS: ui/design] UI/디자인 산출물. 일관성·정보위계·재사용·접근성을 1급 기준으로. \"동작하니 됐다\" 금지." },
432
+ { keys: ["bug", "fix", "버그", "수정"], focus: "[DOMAIN_FOCUS: bugfix] 버그 수정. 근본 원인을 먼저 특정하고, 재현 → 수정 → 회귀 방지 순서. 증상만 가리는 땜질 금지." },
433
+ ];
434
+
435
+ function deriveDomainFocus(entry): string {
436
+ if (!entry) return "";
437
+ const tags = Array.isArray(entry.tags) ? entry.tags.map(t => String(t).toLowerCase()) : [];
438
+ const hay = `${tags.join(" ")} ${String(entry.summary || "")} ${String(entry.title || "")}`.toLowerCase();
439
+ for (const rule of DOMAIN_FOCUS_RULES) {
440
+ if (rule.keys.some(k => hay.includes(k))) return rule.focus;
441
+ }
442
+ return "";
443
+ }
444
+
445
+ // 워크스페이스별 기술 스택/관습 프로필 SSOT: <workspace>/.deuk-agent/workspace-profile.md.
446
+ // 있으면 역할 프롬프트에 주입, 없으면 빈 문자열(역할 기본 문안만).
447
+ function readWorkspaceProfile(cwd, workspace, platform = null): string {
448
+ // 워크스페이스 프로필 SSOT = PROJECT_RULE.md (홈 스토어, reconcile이 보존하는 알려진 파일).
449
+ // 별도 파일을 만들지 않는다 — 설계상 PROJECT_RULE.md가 워크스페이스 규칙+내부기준문서
450
+ // 포인터를 겸한다.
451
+ //
452
+ // 중복 회피: non-claude 플랫폼은 surface가 이미 `## Project Rules`로 PROJECT_RULE.md
453
+ // 전문을 보여주므로(readProjectRuleSurface) 여기선 프로필을 다시 싣지 않는다. claude는
454
+ // `## Project Rules`를 스킵(CLAUDE.md 중복 방지)하므로 ROLE_CONTEXT가 프로필을 담당한다.
455
+ const detectedPlatform = platform || detectCurrentPlatform();
456
+ if (detectedPlatform !== "claude") return "";
457
+ if (!cwd) return "";
458
+ try {
459
+ const p = makePath(resolveHomeTicketDirForWorkspace(cwd), "PROJECT_RULE.md");
460
+ if (existsSync(p)) {
461
+ const raw = stripLeadingFrontMatter(readFileSync(p, "utf8")).trim();
462
+ if (raw) return `[WORKSPACE_PROFILE: ${workspace || "local"}]\n${raw}`;
463
+ }
464
+ } catch { /* ignore */ }
465
+ return "";
466
+ }
467
+
468
+ // 역할 동적 컨텍스트 블록. 도메인/프로필 중 있는 것만 모아 한 섹션으로 낸다.
469
+ // 둘 다 없으면 빈 문자열(템플릿에서 빈 줄로 흡수).
470
+ function buildRoleContextBlock(domainFocus, workspaceProfile): string {
471
+ const parts = [domainFocus, workspaceProfile].map(s => String(s || "").trim()).filter(Boolean);
472
+ if (parts.length === 0) return "";
473
+ return `## Role Context (이번 작업 동적 컨텍스트)\n\n현재 페이즈 역할 레이어에 다음을 함께 적용한다:\n\n${parts.join("\n\n")}`;
474
+ }
475
+
426
476
  export function buildTicketRulesGuide(opts: CliOpts = {}) {
427
477
  const surface = String(opts.ruleSurface || "ticket").trim() || "ticket";
428
478
  if (surface === "ticket") {
@@ -430,12 +480,14 @@ export function buildTicketRulesGuide(opts: CliOpts = {}) {
430
480
  // 없으면 진입 영역 rules. 스킬은 이 영역에 바인딩된 것만 노출(노드별 도구 바인딩).
431
481
  let activePolicyRole = null;
432
482
  let area = "rules";
483
+ let activeEntry = null;
433
484
  try {
434
485
  const index = readTicketIndexJson(opts.cwd);
435
486
  const activeId = index?.activeTicketId || null;
436
487
  if (activeId) {
437
488
  const entry = (index?.entries || []).find(e => e.id === activeId);
438
489
  if (entry) {
490
+ activeEntry = entry;
439
491
  const runtimeState = resolveTicketWorkflowRuntimeState(entry, entry.status);
440
492
  activePolicyRole = runtimeState?.state?.policyRole ?? "analysis";
441
493
  area = runtimeState?.name || `phase${entry.phase || 1}`;
@@ -445,6 +497,11 @@ export function buildTicketRulesGuide(opts: CliOpts = {}) {
445
497
 
446
498
  const guardrailsOpts = { ...opts, activePolicyRole };
447
499
 
500
+ // 역할 동적 컨텍스트: 티켓 APC/도메인 + 워크스페이스 프로필. 네이티브 플랫폼(claude)은
501
+ // 스킬 본문을 정적 파일로 깔아 본문 치환이 안 통하므로, 동적 컨텍스트는 rules surface의
502
+ // 별도 인라인 블록(ROLE_CONTEXT)으로 항상 출력한다(NEXT_ACTION과 동일 방식).
503
+ const roleContext = buildRoleContextBlock(deriveDomainFocus(activeEntry), readWorkspaceProfile(opts.cwd, opts.workspace, opts.platform));
504
+
448
505
  // #685: PROJECT_MEMORY 제거(.md가 state SSOT). PROJECT_RULES는 도메인 케이스만.
449
506
  // #694: TICKET_WORKFLOW 표 덤프 제거 — 워크플로 노드/전이는 XState 머신(SSOT)이고
450
507
  // 표를 매번 쏟는 건 노이즈다. 스킬은 현재 area 바인딩분만 노출.
@@ -453,6 +510,7 @@ export function buildTicketRulesGuide(opts: CliOpts = {}) {
453
510
  PROJECT_RULES: readProjectRuleSurface(opts.cwd, opts.platform),
454
511
  PROJECT_GUARDRAILS: readProjectPolicySurface(opts.cwd, { ...guardrailsOpts, platform: opts.platform }),
455
512
  AGENT_SKILLS: getActiveSkillsSurface(opts.cwd, opts.platform, area),
513
+ ROLE_CONTEXT: roleContext,
456
514
  NEXT_ACTION: buildNextActionBlock(opts)
457
515
  });
458
516
  }
@@ -0,0 +1,103 @@
1
+ import YAML from "yaml";
2
+
3
+ export function splitTicketDocMetaSection(content = "") {
4
+ const src = String(content || "");
5
+ const lines = src.split(/\r?\n/);
6
+ const start = lines.findIndex(line => /^##\s+DocMeta\s*$/i.test(line.trim()));
7
+ if (start === -1) {
8
+ return { body: src, docmetaRaw: "", docmeta: null };
9
+ }
10
+
11
+ const body = lines.slice(0, start).join("\n").trimEnd();
12
+ const docmetaSection = lines.slice(start).join("\n").trim();
13
+ const fence = docmetaSection.match(/```(?:ya?ml)?\s*\n([\s\S]*?)\n```/i);
14
+ const docmetaRaw = fence ? fence[1].trim() : "";
15
+ let docmeta = null;
16
+ if (docmetaRaw) {
17
+ try {
18
+ docmeta = YAML.parse(docmetaRaw) || null;
19
+ } catch {
20
+ docmeta = null;
21
+ }
22
+ }
23
+ return { body, docmetaRaw, docmeta };
24
+ }
25
+
26
+ export function buildDocMetaFrontmatterSummary(docmeta = null) {
27
+ if (!docmeta || typeof docmeta !== "object") return {};
28
+ const errors = Array.isArray(docmeta.validation?.errors)
29
+ ? docmeta.validation.errors.map(value => String(value)).filter(Boolean)
30
+ : [];
31
+ return Object.fromEntries(Object.entries({
32
+ docmetaStatus: docmeta.output_status || undefined,
33
+ docmetaTarget: docmeta.target_state || docmeta.targetState || undefined,
34
+ docmetaValidation: docmeta.validation?.status || undefined,
35
+ docmetaErrors: errors.length ? errors : undefined
36
+ }).filter(([, value]) => value !== undefined && value !== ""));
37
+ }
38
+
39
+ export function clearDocMetaFrontmatterSummary(meta: Record<string, any> = {}) {
40
+ const next = { ...meta };
41
+ delete next.docmeta;
42
+ delete next.docmetaStatus;
43
+ delete next.docmetaTarget;
44
+ delete next.docmetaValidation;
45
+ delete next.docmetaErrors;
46
+ return next;
47
+ }
48
+
49
+ export function appendTicketDocMetaSection(content = "", docmeta = null) {
50
+ const body = splitTicketDocMetaSection(content).body.trimEnd();
51
+ if (!docmeta || typeof docmeta !== "object") return `${body}\n`;
52
+ const yamlStr = YAML.stringify(docmeta).trim();
53
+ return `${body}\n\n## DocMeta\n\n\`\`\`yaml\n${yamlStr}\n\`\`\`\n`;
54
+ }
55
+
56
+ export function buildTransitionValidationDocMeta(input: Record<string, any> = {}) {
57
+ const {
58
+ ticketId = "",
59
+ phase = 1,
60
+ status = "open",
61
+ currentState = `phase${phase || 1}`,
62
+ targetState = "",
63
+ requiredSlots = [],
64
+ slotValues = {},
65
+ slotSourceMap = {}
66
+ } = input;
67
+ const slots = Object.fromEntries(requiredSlots.map(slot => [slot, Boolean(slotValues[slot])]));
68
+ const filteredSlotSourceMap = Object.fromEntries(
69
+ requiredSlots.map(slot => [slot, slotSourceMap[slot] || { source: "unknown" }])
70
+ );
71
+ const errors = Object.entries(slots)
72
+ .filter(([, passed]) => !passed)
73
+ .map(([slot]) => slot);
74
+ return {
75
+ document_type: "ticket_validation",
76
+ document_subtype: "ticket_workflow_transition_gate",
77
+ contract_version: "documeta-0.1",
78
+ ticket_id: ticketId,
79
+ state: {
80
+ name: currentState,
81
+ phase,
82
+ status: String(status || "open")
83
+ },
84
+ target_state: targetState,
85
+ source_contract: {
86
+ required_slots: requiredSlots,
87
+ metadata_fields: ["ticket_id", "workspace_id", "phase", "status", "target_state", "source_path"]
88
+ },
89
+ slots,
90
+ slot_source_map: filteredSlotSourceMap,
91
+ validation: {
92
+ status: errors.length === 0 ? "PASS" : "NEEDS_FIX",
93
+ errors
94
+ },
95
+ output_status: errors.length === 0 ? "transition_allowed" : "transition_blocked",
96
+ adapter_contracts: {
97
+ cli: {
98
+ action: targetState === "phase2" ? "ticket move" : "ticket workflow transition",
99
+ stop_if_output_status: "transition_blocked"
100
+ }
101
+ }
102
+ };
103
+ }
@@ -0,0 +1,153 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { basename } from "path";
3
+ import {
4
+ DEUK_ROOT_DIR,
5
+ computeTicketPath,
6
+ detectConsumerTicketDir,
7
+ normalizeTicketGroup,
8
+ parseFrontMatter,
9
+ stringifyFrontMatter,
10
+ writeFileLF,
11
+ makePath
12
+ , CliOpts } from "./cli-utils.js";
13
+ import { appendTicketDocMetaSection, splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
14
+ import { normalizeMarkdownContent } from "./lint-md.js";
15
+
16
+ export function resolveTicketPath(cwd, relPath) {
17
+ // #080: stale entries (old INDEX/claim/frontmatter) may still carry the legacy
18
+ // `.deuk-agent/tickets/` prefix from in-workspace storage. The base passed here is
19
+ // the ticket root itself post-#622, so strip the prefix instead of nesting.
20
+ let rel = String(relPath || "");
21
+ const legacyPrefix = `${DEUK_ROOT_DIR}/tickets/`;
22
+ if (rel.startsWith(legacyPrefix)) rel = rel.slice(legacyPrefix.length);
23
+ return makePath(cwd, rel);
24
+ }
25
+
26
+ export function resolveTicketEntryPath(cwd, entry) {
27
+ return resolveTicketPath(cwd, entry?.path || "");
28
+ }
29
+
30
+ export function resolveTicketEntryOrComputedPath(cwd, entry) {
31
+ if (entry?.path) {
32
+ const fallbackPath = resolveTicketPath(cwd, entry.path);
33
+ if (existsSync(fallbackPath)) {
34
+ return fallbackPath;
35
+ }
36
+ }
37
+
38
+ const computedPath = resolveTicketPath(cwd, computeTicketPath(entry));
39
+ if (existsSync(computedPath)) {
40
+ return computedPath;
41
+ }
42
+
43
+ const ticketDir = detectConsumerTicketDir(cwd);
44
+ if (!ticketDir) {
45
+ return computedPath;
46
+ }
47
+
48
+ const fileName = entry?.fileName
49
+ ? String(entry.fileName)
50
+ : basename(entry?.path || computeTicketPath(entry));
51
+
52
+ if (!fileName) {
53
+ return computedPath;
54
+ }
55
+
56
+ const group = normalizeTicketGroup(entry?.group || "sub");
57
+ const archivedYearMonth = String(entry?.archiveYearMonth || "").trim();
58
+ const archivedDay = String(entry?.archiveDay || "").trim();
59
+ if (String(entry?.status || "").toLowerCase() === "archived" && archivedYearMonth && archivedDay) {
60
+ const dayPath = makePath(
61
+ ticketDir,
62
+ "archive",
63
+ group,
64
+ archivedYearMonth,
65
+ archivedDay,
66
+ fileName
67
+ );
68
+ if (existsSync(dayPath)) {
69
+ return dayPath;
70
+ }
71
+ }
72
+
73
+ const candidates = readdirSync(ticketDir, { withFileTypes: true });
74
+ const stack = [...candidates.filter(item => item.isDirectory()).map(item => makePath(ticketDir, item.name))];
75
+ while (stack.length > 0) {
76
+ const dir = stack.pop();
77
+ if (!existsSync(dir)) continue;
78
+
79
+ const entries = readdirSync(dir, { withFileTypes: true });
80
+ for (const item of entries) {
81
+ if (item.isDirectory()) {
82
+ if (item.name === ".git" || item.name === "node_modules") continue;
83
+ stack.push(makePath(dir, item.name));
84
+ continue;
85
+ }
86
+ if (item.isFile() && item.name === fileName) {
87
+ return makePath(dir, item.name);
88
+ }
89
+ }
90
+ }
91
+
92
+ return computedPath;
93
+ }
94
+
95
+ export function readTicketDocument(cwd, entry, options: Record<string, any> = {}) {
96
+ const shouldCompute = options.computePath || !entry?.path;
97
+ const absPath = shouldCompute
98
+ ? resolveTicketEntryOrComputedPath(cwd, entry)
99
+ : resolveTicketEntryPath(cwd, entry);
100
+ const fallbackAbsPath = shouldCompute ? absPath : resolveTicketEntryOrComputedPath(cwd, entry);
101
+ const finalAbsPath = absPath === fallbackAbsPath ? absPath : fallbackAbsPath;
102
+
103
+ const parse = options.parse ?? true;
104
+ const requireExists = options.requireExists ?? true;
105
+ const action = options.action || "ticket";
106
+
107
+ const exists = existsSync(finalAbsPath);
108
+ if (!exists && requireExists) {
109
+ const target = entry?.path || "unknown";
110
+ throw new Error(`${action}: Ticket file not found: ${target}`);
111
+ }
112
+
113
+ if (!exists) {
114
+ return {
115
+ absPath: finalAbsPath,
116
+ exists: false,
117
+ body: "",
118
+ meta: {},
119
+ content: "",
120
+ docmeta: null,
121
+ docmetaRaw: ""
122
+ };
123
+ }
124
+
125
+ const body = readFileSync(finalAbsPath, "utf8");
126
+ if (!parse) {
127
+ return { absPath: finalAbsPath, exists: true, body, meta: {}, content: "", docmeta: null, docmetaRaw: "" };
128
+ }
129
+
130
+ const parsed = parseFrontMatter(body);
131
+ const ticketDocMeta = splitTicketDocMetaSection(parsed.content || "");
132
+ return {
133
+ absPath: finalAbsPath,
134
+ exists: true,
135
+ body,
136
+ ...parsed,
137
+ content: ticketDocMeta.body,
138
+ docmeta: ticketDocMeta.docmeta,
139
+ docmetaRaw: ticketDocMeta.docmetaRaw
140
+ };
141
+ }
142
+
143
+ export function renderTicketDocument({ meta = {} as Record<string, any>, content = "", docmeta = null } = {}) {
144
+ return stringifyFrontMatter(meta, appendTicketDocMetaSection(content, docmeta));
145
+ }
146
+
147
+ export function writeTicketMarkdownFile(absPath, body) {
148
+ writeFileLF(absPath, normalizeMarkdownContent(body));
149
+ }
150
+
151
+ export function writeTicketDocument(absPath, doc = {}) {
152
+ writeTicketMarkdownFile(absPath, renderTicketDocument(doc));
153
+ }
@@ -0,0 +1,312 @@
1
+ import { CliOpts, toSnakeCaseKey } from "./cli-utils.js";
2
+
3
+ export function hasPlaceholderTokens(text) {
4
+ const lines = String(text || "")
5
+ .split("\n")
6
+ .map(line => line.trim().toLowerCase())
7
+ .filter(Boolean);
8
+
9
+ return lines.some(line => {
10
+ const normalized = line
11
+ .replace(/^[-*]\s+/, "")
12
+ .replace(/^\d+\.\s+/, "")
13
+ .trim();
14
+ return /^\[(?:add|fill)[^\]]*\]$/.test(normalized)
15
+ || /^<[^>]*(?:placeholder|todo|tbd|fill|add)[^>]*>$/.test(normalized)
16
+ || /^(?:placeholder|todo|tbd)$/.test(normalized);
17
+ });
18
+ }
19
+
20
+ export const ANALYSIS_DESIGN_SECTION_REQUIREMENTS = [
21
+ {
22
+ section: "Analysis",
23
+ reason: "analysis_missing",
24
+ scaffolds: [
25
+ "Record root-cause analysis, relevant file:line evidence, and hypotheses here."
26
+ ]
27
+ },
28
+ {
29
+ section: "Direction",
30
+ reason: "direction_missing",
31
+ scaffolds: [
32
+ "Record the proposed fix direction or follow-up design path."
33
+ ]
34
+ }
35
+ ];
36
+
37
+ export const REQUIRED_PHASE1_SECTIONS = [
38
+ "Scope & Approval",
39
+ "Plan",
40
+ "Analysis",
41
+ "Direction"
42
+ ];
43
+
44
+ export const REQUIRED_APC_MARKERS = [];
45
+
46
+ const PHASE1_DATA_SECTIONS = [
47
+ "Plan",
48
+ "Analysis",
49
+ "Direction"
50
+ ];
51
+
52
+ function dataSectionsForType(_type = "") {
53
+ return [...PHASE1_DATA_SECTIONS];
54
+ }
55
+
56
+ export function resolveTicketType(_opts) {
57
+ return "standard";
58
+ }
59
+
60
+ const FOLLOW_UP_DECISION_NO_FOLLOW_UP_PATTERNS = [
61
+ /\bno[- ]follow[- ]up\b/i,
62
+ /\bfollow[- ]up\s*:\s*none\b/i,
63
+ /\bno further action\b/i,
64
+ /\bnone required\b/i,
65
+ /후속(?:\s+작업)?\s*(?:없음|불필요)/,
66
+ /추가(?:\s+작업)?\s*(?:없음|불필요)/
67
+ ];
68
+
69
+ export function parseMarkdownH2Sections(content) {
70
+ const lines = String(content || "").split("\n");
71
+ const sections = new Map();
72
+ let currentHeading = "";
73
+ let buffer = [];
74
+
75
+ const flush = () => {
76
+ if (!currentHeading) return;
77
+ sections.set(currentHeading, buffer.join("\n").trim());
78
+ };
79
+
80
+ for (const line of lines) {
81
+ const headingMatch = line.match(/^##\s+(.+?)\s*$/);
82
+ if (headingMatch) {
83
+ flush();
84
+ currentHeading = headingMatch[1].trim();
85
+ buffer = [];
86
+ continue;
87
+ }
88
+ if (currentHeading) {
89
+ buffer.push(line);
90
+ }
91
+ }
92
+
93
+ flush();
94
+ return sections;
95
+ }
96
+
97
+ const PHASE1_HEADING_ALIASES = new Map([
98
+ ["apc", "Scope & Approval"],
99
+ ["agent permission contract", "Scope & Approval"],
100
+ ["agent permission contract (apc)", "Scope & Approval"],
101
+ ["scope", "Scope & Approval"],
102
+ ["compact plan", "Plan"],
103
+ ["problem analysis", "Analysis"],
104
+ ["source observations", "Analysis"],
105
+ ["cause hypotheses", "Analysis"],
106
+ ["audit evidence", "Analysis"],
107
+ ["improvement direction", "Direction"],
108
+ ...REQUIRED_PHASE1_SECTIONS.map(section => [section.toLowerCase(), section] as [string, string])
109
+ ]);
110
+
111
+ export function normalizePhase1PlanBodyHeadings(body) {
112
+ return String(body || "").split("\n").map((line) => {
113
+ const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
114
+ if (!match) return line;
115
+ const normalized = PHASE1_HEADING_ALIASES.get(match[1].trim().toLowerCase());
116
+ return normalized ? `## ${normalized}` : line;
117
+ }).join("\n");
118
+ }
119
+
120
+ export function extractMarkdownSection(content, heading) {
121
+ return parseMarkdownH2Sections(content).get(String(heading || "").trim()) || "";
122
+ }
123
+
124
+ function extractMarkdownSectionByAliases(content, aliases) {
125
+ const sections = parseMarkdownH2Sections(content);
126
+ for (const alias of aliases) {
127
+ const value = sections.get(String(alias || "").trim());
128
+ if (value !== undefined) return value;
129
+ }
130
+ return "";
131
+ }
132
+
133
+ function hasSubstantiveSectionContent(text, scaffolds = []) {
134
+ const src = String(text || "").trim();
135
+ if (!src || hasPlaceholderTokens(src)) return false;
136
+ const normalized = src.replace(/\s+/g, " ");
137
+ return !scaffolds.some(phrase => normalized.includes(phrase));
138
+ }
139
+
140
+ function hasApcMarker(text, marker) {
141
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ const markerPattern = new RegExp(`^\\s*(?:#{1,6}\\s+)?${escapedMarker}(?:\\s|$)`);
143
+ return String(text || "").split("\n").some(line => markerPattern.test(line));
144
+ }
145
+
146
+ function getApcContentWithoutMarkers(text) {
147
+ return String(text || "")
148
+ .split("\n")
149
+ .map(line => {
150
+ for (const { marker } of REQUIRED_APC_MARKERS) {
151
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
+ const markerPattern = new RegExp(`^\\s*(?:#{1,6}\\s+)?${escapedMarker}\\s*`);
153
+ if (markerPattern.test(line)) return line.replace(markerPattern, "");
154
+ }
155
+ return line;
156
+ })
157
+ .join("\n")
158
+ .trim();
159
+ }
160
+
161
+ function getMissingApcFields(text) {
162
+ return REQUIRED_APC_MARKERS
163
+ .filter(({ marker }) => !hasApcMarker(text, marker))
164
+ .map(field => field.name);
165
+ }
166
+
167
+ export function getPhase1PlanBodyReasons(body, opts: CliOpts = {}) {
168
+ const content = String(body || "");
169
+ const sections = parseMarkdownH2Sections(content);
170
+ const reasons = [];
171
+
172
+ for (const sectionName of ["Scope & Approval", ...dataSectionsForType()]) {
173
+ const sectionKey = toSnakeCaseKey(sectionName);
174
+ if (!sections.has(sectionName)) {
175
+ reasons.push(`${sectionKey}_missing`);
176
+ continue;
177
+ }
178
+ if (!hasSubstantiveSectionContent(sections.get(sectionName), [])) {
179
+ reasons.push(`${sectionKey}_incomplete`);
180
+ }
181
+ }
182
+
183
+ return reasons;
184
+ }
185
+
186
+ export function phase1SectionsForType(_type = "") {
187
+ return ["Agent Permission Contract (APC)", ...dataSectionsForType()];
188
+ }
189
+
190
+ export function getAnalysisDesignIncompleteReasons(meta, content) {
191
+ const reasons = [];
192
+ for (const requirement of ANALYSIS_DESIGN_SECTION_REQUIREMENTS) {
193
+ if (!hasSubstantiveSectionContent(extractMarkdownSection(content, requirement.section), requirement.scaffolds)) {
194
+ reasons.push(requirement.reason);
195
+ }
196
+ }
197
+
198
+ return reasons;
199
+ }
200
+
201
+ export function hasSubstantiveFollowUpDecision(content) {
202
+ return hasSubstantiveSectionContent(extractMarkdownSection(content, "Follow-up Decision"), []);
203
+ }
204
+
205
+ export function followUpDecisionMeansNoFollowUp(content) {
206
+ const text = extractMarkdownSection(content, "Follow-up Decision");
207
+ if (!hasSubstantiveSectionContent(text, [])) return false;
208
+ return FOLLOW_UP_DECISION_NO_FOLLOW_UP_PATTERNS.some(pattern => pattern.test(text));
209
+ }
210
+
211
+ export function hasTicketCompletionEvidence(content) {
212
+ return hasSubstantiveSectionContent(extractMarkdownSection(content, "Completion Report"), [])
213
+ || hasSubstantiveSectionContent(extractMarkdownSection(content, "Verification Outcome"), [])
214
+ || followUpDecisionMeansNoFollowUp(content);
215
+ }
216
+
217
+ const PHASE2_EXECUTION_SLOT_HEADINGS = [
218
+ "실행 슬롯",
219
+ "Execution Slots",
220
+ "Execution",
221
+ ];
222
+
223
+ const PHASE2_EXECUTION_SLOT_FIELDS = [
224
+ /files\s+changed\s*:/i,
225
+ /commands?\s+run\s*:/i,
226
+ /interim\s+evidence\s*:/i,
227
+ /approved\s+scope\s*:/i,
228
+ ];
229
+
230
+ export function hasPhase2ExecutionEvidence(content) {
231
+ const body = String(content || "");
232
+ // Check any phase2 execution slot heading section has non-empty content
233
+ for (const heading of PHASE2_EXECUTION_SLOT_HEADINGS) {
234
+ const section = extractMarkdownSection(body, heading);
235
+ if (section) {
236
+ const hasFilledField = PHASE2_EXECUTION_SLOT_FIELDS.some(pattern => {
237
+ const match = section.match(new RegExp(pattern.source + "\\s*(.+)", "i"));
238
+ return match && match[1] && match[1].trim().length > 0;
239
+ });
240
+ if (hasFilledField) return true;
241
+ }
242
+ }
243
+ // Fallback: any substantive content in Analysis or Direction (for bug-fix style tickets)
244
+ return hasSubstantiveSectionContent(extractMarkdownSection(body, "Analysis"), [])
245
+ || hasSubstantiveSectionContent(extractMarkdownSection(body, "Problem Analysis"), []);
246
+ }
247
+
248
+ // Compact, .md-aware slot status so `ticket use` shows what is already filled
249
+ // vs. still empty, plus affected files — preventing the agent from re-reading the
250
+ // ticket .md / find-grepping the tree to reconstruct context. Output stays terse.
251
+ const SLOT_STATUS_SECTIONS_BY_PHASE = {
252
+ 1: ["Scope & Approval", "Plan", "Analysis", "Direction"],
253
+ 2: ["Scope & Approval", "Plan", "Analysis", "Direction"],
254
+ 3: ["Analysis", "Direction", "Execution Result", "Verification"],
255
+ 4: ["Execution Result", "Verification", "Residual Risk", "Follow-up Decision"]
256
+ };
257
+
258
+ // Pull file-ish tokens (path/with/slashes or name.ext) from the sections most
259
+ // likely to record affected files, capped so the line never sprawls.
260
+ function extractAffectedFiles(content, limit = 6) {
261
+ const src = [
262
+ extractMarkdownSection(content, "Execution Result"),
263
+ extractMarkdownSection(content, "Scope & Approval"),
264
+ extractMarkdownSection(content, "Plan")
265
+ ].join("\n");
266
+ const matches = src.match(/[\w./-]+\.[A-Za-z][\w]{0,4}\b/g) || [];
267
+ const seen = [];
268
+ for (const m of matches) {
269
+ const f = m.replace(/^[-*\s]+/, "");
270
+ if (!seen.includes(f)) seen.push(f);
271
+ if (seen.length >= limit) break;
272
+ }
273
+ return seen;
274
+ }
275
+
276
+ export function buildSlotStatusSummary(content, phase = 2) {
277
+ const sections = SLOT_STATUS_SECTIONS_BY_PHASE[phase >= 4 ? 4 : phase] || SLOT_STATUS_SECTIONS_BY_PHASE[2];
278
+ const filled = [];
279
+ const empty = [];
280
+ for (const name of sections) {
281
+ if (hasSubstantiveSectionContent(extractMarkdownSection(content, name), [])) filled.push(name);
282
+ else empty.push(name);
283
+ }
284
+ const files = extractAffectedFiles(content);
285
+ const lines = [];
286
+ lines.push(`[filled] ${filled.length ? filled.join(" · ") : "(none)"}`);
287
+ lines.push(`[empty] ${empty.length ? empty.join(" · ") : "(none)"}`);
288
+ if (files.length) lines.push(`[files] ${files.join(", ")}`);
289
+ return lines.join("\n");
290
+ }
291
+
292
+ export function getCloseWorkflowReasons(meta, content) {
293
+ const reasons = [];
294
+ const analysisScaffolds = ANALYSIS_DESIGN_SECTION_REQUIREMENTS[0].scaffolds;
295
+ const directionScaffolds = ANALYSIS_DESIGN_SECTION_REQUIREMENTS[1].scaffolds;
296
+ // Accept both canonical ("Analysis") and legacy ("Problem Analysis") heading names.
297
+ const analysisText = extractMarkdownSection(content, "Analysis")
298
+ || extractMarkdownSection(content, "Problem Analysis");
299
+ if (!hasSubstantiveSectionContent(analysisText, analysisScaffolds)) {
300
+ reasons.push("problem_analysis_missing");
301
+ }
302
+
303
+ // Accept both canonical ("Direction") and legacy ("Improvement Direction") heading names.
304
+ const directionText = extractMarkdownSection(content, "Direction")
305
+ || extractMarkdownSection(content, "Improvement Direction");
306
+ if (!hasSubstantiveSectionContent(directionText, directionScaffolds)
307
+ && !hasSubstantiveFollowUpDecision(content)) {
308
+ reasons.push("follow_up_decision_missing");
309
+ }
310
+
311
+ return reasons;
312
+ }