vibe-commander 0.2.4 → 0.2.6

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.
@@ -10,7 +10,7 @@
10
10
  * @module
11
11
  */
12
12
  import type { ToolResult, ScanResult, InitAnswers, InitResult } from '../../../types/index.js';
13
- export { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, DEFAULT_HEADER_TEMPLATE, extractStdinAnswers, buildAnswersFromScan, } from '../../../core/resolvers/config-generator.js';
13
+ export { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_RUNBOOK_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, DEFAULT_HEADER_TEMPLATE, extractStdinAnswers, buildAnswersFromScan, } from '../../../core/resolvers/config-generator.js';
14
14
  /**
15
15
  * 수집된 정보로 설정 객체를 구성하고, 검증 후 파일에 쓴다
16
16
  *
@@ -15,14 +15,14 @@ import { projectConfigSchema } from '../../../config/schema.js';
15
15
  import { CYAN, YELLOW, GREEN, DIM, RESET, BOLD } from '../output.js';
16
16
  import { mergeConfigs } from '../../../core/resolvers/config-merger.js';
17
17
  import { buildConfigObject } from '../../../core/resolvers/config-generator.js';
18
- export { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, DEFAULT_HEADER_TEMPLATE, extractStdinAnswers, buildAnswersFromScan, } from '../../../core/resolvers/config-generator.js';
18
+ export { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_RUNBOOK_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, DEFAULT_HEADER_TEMPLATE, extractStdinAnswers, buildAnswersFromScan, } from '../../../core/resolvers/config-generator.js';
19
19
  /**
20
20
  * 수집된 정보로 설정 객체를 구성하고, 검증 후 파일에 쓴다
21
21
  *
22
22
  * @param mergeWith - 기존 설정 객체. 제공되면 스캔 결과와 병합한다.
23
23
  */
24
24
  export function buildAndWriteConfig(answers, configPath, alreadyExists, scanResult, mergeWith) {
25
- let configObj = buildConfigObject(answers);
25
+ let configObj = buildConfigObject(answers, scanResult);
26
26
  if (mergeWith) {
27
27
  configObj = mergeConfigs(mergeWith, configObj);
28
28
  }
@@ -17,7 +17,7 @@ import { fail } from '../../../types/index.js';
17
17
  import { CONFIG_FILENAME } from '../../../config/schema.js';
18
18
  import { isValidRegex } from '../../../core/resolvers/config-validator.js';
19
19
  import { CYAN, YELLOW, DIM, RESET, BOLD } from '../output.js';
20
- import { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, buildAnswersFromScan, buildAndWriteConfig, printScanResults, } from './init-helpers.js';
20
+ import { DEFAULT_COMMANDS_PATH, DEFAULT_ROADMAP_PATH, DEFAULT_PLAN_DIR, DEFAULT_RESULT_DIR, DEFAULT_RUNBOOK_DIR, DEFAULT_ID_PATTERN, DEFAULT_COMMAND_SECTION, buildAnswersFromScan, buildAndWriteConfig, printScanResults, } from './init-helpers.js';
21
21
  /**
22
22
  * 대화형 모드 — readline으로 질문을 수집하고 설정 파일을 생성한다
23
23
  */
@@ -105,7 +105,7 @@ async function collectAnswers(rl) {
105
105
  const roadmapPath = await askNullable(rl, '로드맵 파일 경로 (없으면 Enter)', DEFAULT_ROADMAP_PATH);
106
106
  const planDir = await askWithDefault(rl, '계획서 디렉토리', DEFAULT_PLAN_DIR);
107
107
  const resultDir = await askWithDefault(rl, '결과 보고서 디렉토리', DEFAULT_RESULT_DIR);
108
- const runbookDir = await askNullable(rl, '런북 디렉토리 (없으면 Enter)', '');
108
+ const runbookDir = await askNullable(rl, '런북 디렉토리 (없으면 Enter)', DEFAULT_RUNBOOK_DIR);
109
109
  const idPattern = await askWithDefault(rl, '유닛 ID 패턴 (정규식)', DEFAULT_ID_PATTERN);
110
110
  const validPattern = isValidRegex(idPattern);
111
111
  if (!validPattern) {
@@ -132,7 +132,7 @@ async function askWithDefault(rl, prompt, defaultValue) {
132
132
  /**
133
133
  * null 가능한 질문 프롬프트 (빈 입력 → null)
134
134
  */
135
- async function askNullable(rl, prompt, defaultValue) {
135
+ async function askNullable(rl, prompt, defaultValue = '') {
136
136
  const display = defaultValue ? ` ${DIM}[${defaultValue}]${RESET}` : '';
137
137
  const answer = await rl.question(`${CYAN}? ${prompt}${RESET}${display}: `);
138
138
  const trimmed = answer.trim();
@@ -24,6 +24,8 @@ export interface SetUnitResult {
24
24
  pairingQuestions: PairingQuestion[];
25
25
  pairingAnswers: PairingAnswer[];
26
26
  commandsUpdated: boolean;
27
+ planUpdated?: boolean;
28
+ questionsWrittenBack?: number;
27
29
  warnings: string[];
28
30
  dryRun?: boolean;
29
31
  preview?: string;
@@ -18,6 +18,7 @@ import { parseBacklog } from '../../../core/parsers/backlog-parser.js';
18
18
  import { classifyBacklog } from '../../../core/resolvers/backlog-resolver.js';
19
19
  import { renderSection } from '../../../core/renderers/section-renderer.js';
20
20
  import { updateSection } from '../../../core/renderers/section-updater.js';
21
+ import { updatePlanQuestions } from '../../../core/renderers/plan-question-updater.js';
21
22
  import { findMarkerRange } from '../../../core/renderers/marker-utils.js';
22
23
  import { extractSubsectionContent } from '../../../core/parsers/subsection-extractor.js';
23
24
  import { normalizeLineEndings } from '../../../core/parsers/md-utils.js';
@@ -76,9 +77,30 @@ export async function handleSetUnit(args, projectRoot) {
76
77
  pairingAnswers = await promptAllQuestions(context.pairingQuestions);
77
78
  context.pairingAnswers = pairingAnswers;
78
79
  }
79
- // 3. 커맨드 파일 읽기 (보존 추출 + 업데이트에 재사용)
80
+ // 3. 계획서 Writeback (답변이 있고 dry-run이 아닐 때만)
81
+ let planUpdated = false;
82
+ let questionsWrittenBack = 0;
83
+ if (!dryRun && pairingAnswers.length > 0) {
84
+ try {
85
+ const planAbsPath = join(projectRoot, planRelPath);
86
+ const planContent = readFileSync(planAbsPath, 'utf-8');
87
+ const writebackResult = updatePlanQuestions(planContent, pairingAnswers, {
88
+ pairingQuestionSection: config.planParsing.pairingQuestionSection,
89
+ });
90
+ if (writebackResult.appliedCount > 0) {
91
+ writeFileSync(planAbsPath, writebackResult.updatedContent, 'utf-8');
92
+ planUpdated = true;
93
+ questionsWrittenBack = writebackResult.appliedCount;
94
+ }
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ warnings.push(`계획서 Writeback 실패: ${msg}`);
99
+ }
100
+ }
101
+ // 4. 커맨드 파일 읽기 (보존 추출 + 업데이트에 재사용)
80
102
  const cmdFile = readCommandsFile(config, projectRoot);
81
- // 4. 기존 특별 요청사항 추출 (보존 로직)
103
+ // 5. 기존 특별 요청사항 추출 (보존 로직)
82
104
  const specialRequestsHeading = config.commandSections.specialRequestsHeading;
83
105
  let preservedSpecialRequests;
84
106
  if (cmdFile.success) {
@@ -87,12 +109,12 @@ export async function handleSetUnit(args, projectRoot) {
87
109
  preservedSpecialRequests = extracted;
88
110
  }
89
111
  }
90
- // 5. 섹션 렌더링 (Core 순수 함수)
112
+ // 6. 섹션 렌더링 (Core 순수 함수)
91
113
  const sectionBody = renderSection(context, unitTypeConfig, undefined, {
92
114
  preservedSpecialRequests,
93
115
  specialRequestsHeading,
94
116
  });
95
- // 6. 커맨드 파일 업데이트 (Adapter I/O) — dry-run 시 건너뜀
117
+ // 7. 커맨드 파일 업데이트 (Adapter I/O) — dry-run 시 건너뜀
96
118
  let commandsUpdated = false;
97
119
  if (!dryRun) {
98
120
  if (cmdFile.success) {
@@ -116,10 +138,10 @@ export async function handleSetUnit(args, projectRoot) {
116
138
  else {
117
139
  warnings.push(`커맨드 파일을 읽을 수 없습니다: ${config.paths.commands}`);
118
140
  }
119
- // 7. 활성 유닛 타입 상태 기록 (update-commit 자동 감지용)
141
+ // 8. 활성 유닛 타입 상태 기록 (update-commit 자동 감지용)
120
142
  writeActiveUnitType(projectRoot, unitTypeKey);
121
143
  }
122
- // 6. 결과 반환
144
+ // 9. 결과 반환
123
145
  return ok({
124
146
  unitId,
125
147
  unitType: unitTypeKey,
@@ -131,6 +153,7 @@ export async function handleSetUnit(args, projectRoot) {
131
153
  pairingQuestions: context.pairingQuestions,
132
154
  pairingAnswers,
133
155
  commandsUpdated,
156
+ ...(planUpdated && { planUpdated, questionsWrittenBack }),
134
157
  warnings,
135
158
  ...(dryRun && { dryRun: true, preview: sectionBody }),
136
159
  ...(autoSelected && { autoSelected, selectedFrom }),
@@ -80,19 +80,25 @@ function detectIdPatterns(mdFiles) {
80
80
  // [우선순위 1] PREFIX-NNN[Tag] (예: U-001[Mvp], RU-001[Mmp])
81
81
  const tagMatch = name.match(/^([A-Z][A-Z0-9]*)-\d+\[/);
82
82
  if (tagMatch?.[1]) {
83
- upsertGroup(prefixGroups, tagMatch[1], name, true, false);
83
+ upsertGroup(prefixGroups, tagMatch[1], name, true, false, false);
84
+ continue;
85
+ }
86
+ // [우선순위 1.5] PREFIX-NNN-XN (예: RU-010-S1, RU-013-Q2)
87
+ const subUnitMatch = name.match(/^([A-Z][A-Z0-9]*)-\d+-[A-Z]\d+$/);
88
+ if (subUnitMatch?.[1]) {
89
+ upsertGroup(prefixGroups, subUnitMatch[1], name, false, false, true);
84
90
  continue;
85
91
  }
86
92
  // [우선순위 2] PREFIX-WORD-NNN (예: CP-MVP-01)
87
93
  const compoundMatch = name.match(/^([A-Z][A-Z0-9]*)-[A-Z][A-Z0-9]*-\d+/);
88
94
  if (compoundMatch?.[1]) {
89
- upsertGroup(prefixGroups, compoundMatch[1], name, false, true);
95
+ upsertGroup(prefixGroups, compoundMatch[1], name, false, true, false);
90
96
  continue;
91
97
  }
92
98
  // [우선순위 3] PREFIX-NNN (예: TASK-001)
93
99
  const basicMatch = name.match(/^([A-Z][A-Z0-9]*)-\d+/);
94
100
  if (basicMatch?.[1]) {
95
- upsertGroup(prefixGroups, basicMatch[1], name, false, false);
101
+ upsertGroup(prefixGroups, basicMatch[1], name, false, false, false);
96
102
  }
97
103
  }
98
104
  const patterns = [];
@@ -113,12 +119,14 @@ function detectIdPatterns(mdFiles) {
113
119
  displayLabel,
114
120
  matchCount: group.names.length,
115
121
  examples: group.names.slice(0, 3),
122
+ hasTag: group.hasTag,
123
+ hasSubUnit: group.hasSubUnit,
116
124
  });
117
125
  }
118
126
  patterns.sort((a, b) => b.matchCount - a.matchCount);
119
127
  return patterns;
120
128
  }
121
- function upsertGroup(groups, prefix, name, hasTag, isCompound) {
129
+ function upsertGroup(groups, prefix, name, hasTag, isCompound, hasSubUnit) {
122
130
  const existing = groups.get(prefix);
123
131
  if (existing) {
124
132
  existing.names.push(name);
@@ -126,9 +134,11 @@ function upsertGroup(groups, prefix, name, hasTag, isCompound) {
126
134
  existing.hasTag = true;
127
135
  if (isCompound)
128
136
  existing.isCompound = true;
137
+ if (hasSubUnit)
138
+ existing.hasSubUnit = true;
129
139
  }
130
140
  else {
131
- groups.set(prefix, { names: [name], hasTag, isCompound });
141
+ groups.set(prefix, { names: [name], hasTag, isCompound, hasSubUnit });
132
142
  }
133
143
  }
134
144
  // ── 문서 디렉토리 감지 ──
@@ -136,6 +146,7 @@ const ROLE_KEYWORDS = {
136
146
  plan: ['plan', 'plans', 'unit-plan', 'unit-plans'],
137
147
  result: ['result', 'results', 'unit-result', 'unit-results', 'report', 'reports'],
138
148
  runbook: ['runbook', 'runbooks', 'unit-runbook', 'unit-runbooks'],
149
+ refactorSub: ['refactor', 'refactors', 'refactoring'],
139
150
  };
140
151
  function detectDocRoots(mdFiles) {
141
152
  const dirCounts = new Map();
@@ -159,7 +170,13 @@ function detectDocRoots(mdFiles) {
159
170
  docRoots.push({ role, dirPath: dir, fileCount: count });
160
171
  }
161
172
  }
162
- const roleOrder = { plan: 0, result: 1, runbook: 2, other: 3 };
173
+ const roleOrder = {
174
+ plan: 0,
175
+ result: 1,
176
+ runbook: 2,
177
+ refactorSub: 3,
178
+ other: 4,
179
+ };
163
180
  docRoots.sort((a, b) => {
164
181
  const orderDiff = (roleOrder[a.role] ?? 3) - (roleOrder[b.role] ?? 3);
165
182
  if (orderDiff !== 0)
@@ -43,12 +43,19 @@ export function parseDependencyLines(lines, idPattern) {
43
43
  artifacts: currentArtifacts,
44
44
  });
45
45
  }
46
- // 새 dep 파싱
47
- const parsed = extractDepFromLine(trimmed, idPattern);
48
- if (parsed) {
49
- currentUnitId = parsed.unitId;
50
- currentDescription = parsed.description;
51
- currentArtifacts = parsed.artifacts;
46
+ // 새 dep 파싱 (멀티 반환 지원)
47
+ const parsedList = extractDepFromLine(trimmed, idPattern);
48
+ if (parsedList.length > 0) {
49
+ // 마지막 이외 항목은 즉시 deps에 push (하위 항목은 마지막 유닛에만 연결)
50
+ for (let i = 0; i < parsedList.length - 1; i++) {
51
+ const dep = parsedList[i];
52
+ if (dep)
53
+ deps.push(dep);
54
+ }
55
+ const last = parsedList[parsedList.length - 1];
56
+ currentUnitId = last?.unitId ?? '';
57
+ currentDescription = last?.description ?? '';
58
+ currentArtifacts = last?.artifacts;
52
59
  hasCurrent = true;
53
60
  }
54
61
  else {
@@ -83,49 +90,132 @@ export function parseDependencyLines(lines, idPattern) {
83
90
  return { deps, contextNotes };
84
91
  }
85
92
  /**
86
- * 한 라인에서 의존 유닛 정보를 추출한다
93
+ * 한 라인에서 의존 유닛 정보를 추출한다 (멀티 반환 지원)
87
94
  *
88
95
  * 추출 우선순위:
89
96
  * 1. 링크 텍스트 `[ID](url)` — 더 구체적 (문서 링크 포함)
90
97
  * 2. 볼드 텍스트 `**ID**` — 일반적인 의존성 표기
91
98
  *
92
99
  * idPattern이 제공되면 추출된 후보를 필터링하여 실제 유닛 ID만 선별.
100
+ * 한 줄에 콤마로 구분된 복수 유닛이 있을 경우 모두 추출.
93
101
  *
94
102
  * @param line - 리스트 항목 라인 (리스트 마커 제거 전)
95
103
  * @param idPattern - 유닛 ID 필터링 패턴 (선택)
96
- * @returns 추출된 DepInfo 또는 null
104
+ * @returns 추출된 DepInfo 배열 (없으면 빈 배열)
97
105
  */
98
106
  function extractDepFromLine(line, idPattern) {
99
- // 리스트 마커 제거
100
107
  const content = line.replace(/^[-*]\s+/, '');
101
- // 후보 ID 수집 (링크 우선, 볼드 후순위)
102
108
  const candidates = [];
103
- // 링크 텍스트: [ID](url) — 중첩 괄호 1레벨 지원 (예: [U-002[Mvp]])
104
109
  const linkRegex = /\[((?:[^[\]]*(?:\[[^\]]*\])?[^[\]]*)*)\]\([^)]*\)/g;
105
110
  let match;
106
111
  while ((match = linkRegex.exec(content)) !== null) {
107
112
  if (match[1])
108
- candidates.push({ id: match[1].trim(), source: 'link' });
113
+ candidates.push({
114
+ id: match[1].trim(),
115
+ source: 'link',
116
+ matchStart: match.index,
117
+ matchEnd: match.index + match[0].length,
118
+ });
109
119
  }
110
- // 볼드 텍스트: **ID**
111
120
  const boldRegex = /\*\*([^*]+)\*\*/g;
112
121
  while ((match = boldRegex.exec(content)) !== null) {
113
122
  if (match[1])
114
- candidates.push({ id: match[1].trim(), source: 'bold' });
123
+ candidates.push({
124
+ id: match[1].trim(),
125
+ source: 'bold',
126
+ matchStart: match.index,
127
+ matchEnd: match.index + match[0].length,
128
+ });
129
+ }
130
+ // 3. 평문 ID Fallback: 볼드/링크가 없고 idPattern이 제공된 경우에만 시도
131
+ if (candidates.length === 0 && idPattern) {
132
+ const plainIdRegex = safeRegex(idPattern);
133
+ if (plainIdRegex) {
134
+ // 앵커 제거한 버전으로 전체 스캔 (멀티 ID 지원용)
135
+ const scanPattern = plainIdRegex.source.replace(/^\^/, '');
136
+ const globalIdRegex = new RegExp(scanPattern, 'g');
137
+ let plainMatch;
138
+ while ((plainMatch = globalIdRegex.exec(content)) !== null) {
139
+ // 단, 평문 ID는 반드시 토큰 시작점(공백/마커 직후)이거나 콤마 뒤여야 함
140
+ const charBefore = content[plainMatch.index - 1];
141
+ const isStartOfToken = !charBefore || /[\s,:]/.test(charBefore);
142
+ if (isStartOfToken) {
143
+ candidates.push({
144
+ id: plainMatch[0],
145
+ source: 'plain',
146
+ matchStart: plainMatch.index,
147
+ matchEnd: plainMatch.index + plainMatch[0].length,
148
+ });
149
+ }
150
+ }
151
+ }
115
152
  }
116
153
  if (candidates.length === 0)
117
- return null;
118
- // idPattern으로 필터링: 볼드 헤더(`**헤더**: [ID](url)`)에서 비유닛 볼드를 건너뜀
154
+ return [];
119
155
  const idRegex = idPattern ? safeRegex(idPattern) : null;
120
- const validCandidate = idRegex ? candidates.find((c) => idRegex.test(c.id)) : candidates[0];
121
- if (!validCandidate)
122
- return null;
123
- const unitId = validCandidate.id;
124
- if (isNoneDependency(unitId))
125
- return null;
126
- // 설명 추출: ID 참조 이후의 텍스트에서 구분자 제거
127
- const description = extractDescription(content, unitId, validCandidate.source);
128
- return { unitId, description };
156
+ const validCandidates = idRegex
157
+ ? candidates
158
+ .filter((c) => idRegex.test(c.id))
159
+ .map((c) => {
160
+ // ID 패턴으로 실제 유닛 ID 부분만 추출 (정규화)
161
+ // 예: **[U-101](url)** -> U-101, U-101[Mmp]:설명 -> U-101[Mmp]
162
+ const actualMatch = c.id.match(idRegex);
163
+ if (actualMatch) {
164
+ const normalizedId = actualMatch[0];
165
+ if (c.source === 'plain') {
166
+ const offset = c.id.indexOf(normalizedId);
167
+ const newStart = c.matchStart + (offset !== -1 ? offset : 0);
168
+ return {
169
+ ...c,
170
+ id: normalizedId,
171
+ matchStart: newStart,
172
+ matchEnd: newStart + normalizedId.length,
173
+ };
174
+ }
175
+ else {
176
+ // 볼드/링크: ID만 정규화하고 매칭 범위(마커 포함)는 유지하여 설명 추출 보호
177
+ return { ...c, id: normalizedId };
178
+ }
179
+ }
180
+ return c;
181
+ })
182
+ : candidates[0]
183
+ ? [candidates[0]]
184
+ : [];
185
+ // None 의존성 필터링 + 동일 ID 중복 제거 (첫 등장 유지)
186
+ const seen = new Set();
187
+ const filtered = validCandidates.filter((c) => {
188
+ if (isNoneDependency(c.id))
189
+ return false;
190
+ if (seen.has(c.id))
191
+ return false;
192
+ seen.add(c.id);
193
+ return true;
194
+ });
195
+ if (filtered.length === 0)
196
+ return [];
197
+ // 단일 후보: 기존 extractDescription 로직 사용 (성능/호환성 보장)
198
+ if (filtered.length === 1) {
199
+ const c = filtered[0];
200
+ if (!c)
201
+ return [];
202
+ if (c.source === 'plain') {
203
+ const descText = content.slice(c.matchEnd);
204
+ const description = cleanupMultiDescription(descText);
205
+ return [{ unitId: c.id, description }];
206
+ }
207
+ const description = extractDescription(content, c.id, c.source);
208
+ return [{ unitId: c.id, description }];
209
+ }
210
+ // 복수 후보: 등장 순서 정렬 후 위치 기반 description 범위 분할
211
+ const sorted = [...filtered].sort((a, b) => a.matchStart - b.matchStart);
212
+ return sorted.map((c, i) => {
213
+ const nextCandidate = sorted[i + 1];
214
+ const nextStart = nextCandidate ? nextCandidate.matchStart : content.length;
215
+ const descText = content.slice(c.matchEnd, nextStart);
216
+ const description = cleanupMultiDescription(descText);
217
+ return { unitId: c.id, description };
218
+ });
129
219
  }
130
220
  /**
131
221
  * ID 참조 이후의 설명 텍스트를 추출한다
@@ -157,6 +247,19 @@ function extractDescription(content, unitId, source) {
157
247
  rest = rest.replace(/^[—-]\s+/, '');
158
248
  return rest.trim();
159
249
  }
250
+ /**
251
+ * 멀티 의존성 description 텍스트를 정리한다
252
+ *
253
+ * 위치 기반 범위 분할로 추출된 raw description에서
254
+ * 선행 구분자(`:`, `—`, `-`)와 후행 콤마를 제거.
255
+ */
256
+ function cleanupMultiDescription(text) {
257
+ let rest = text.trim();
258
+ rest = rest.replace(/^[:—]\s*/, '');
259
+ rest = rest.replace(/^[—-]\s+/, '');
260
+ rest = rest.replace(/,\s*$/, '');
261
+ return rest.trim();
262
+ }
160
263
  /**
161
264
  * 하위 항목에서 artifact 텍스트를 추출한다
162
265
  *
@@ -13,6 +13,8 @@ export { updateSection, updateField } from './section-updater.js';
13
13
  export type { UpdateResult, MarkerOptions } from './section-updater.js';
14
14
  export { createBeginMarker, createEndMarker, findMarkerRange, isValidSectionKey, wrapWithMarkers, stripMarkers, } from './marker-utils.js';
15
15
  export type { MarkerRange } from './marker-utils.js';
16
+ export { updatePlanQuestions } from './plan-question-updater.js';
17
+ export type { PlanQuestionUpdateResult } from './plan-question-updater.js';
16
18
  export { COMMAND_REGISTRY, SCHEMA_SUBCOMMANDS, renderConfigSchema, renderCommandsSchema, renderTypesSchema, } from './schema-renderer.js';
17
19
  export type { ArgSchema, FlagSchema, CommandSchema, TypeSchemaEntry, SchemaSubcommandInfo, SchemaResult, } from './schema-renderer.js';
18
20
  //# sourceMappingURL=index.d.ts.map
@@ -15,6 +15,8 @@ export { renderSection } from './section-renderer.js';
15
15
  export { updateSection, updateField } from './section-updater.js';
16
16
  // 마커 유틸리티
17
17
  export { createBeginMarker, createEndMarker, findMarkerRange, isValidSectionKey, wrapWithMarkers, stripMarkers, } from './marker-utils.js';
18
+ // 계획서 질문 Writeback
19
+ export { updatePlanQuestions } from './plan-question-updater.js';
18
20
  // 스키마 렌더러
19
21
  export { COMMAND_REGISTRY, SCHEMA_SUBCOMMANDS, renderConfigSchema, renderCommandsSchema, renderTypesSchema, } from './schema-renderer.js';
20
22
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 계획서 페어링 질문 Writeback — Layer 1 순수 함수
3
+ *
4
+ * 페어링 질문에 대한 CLI 답변을 계획서 원본 마크다운에 반영한다.
5
+ * `[ ]` → `[x]` 체크, 답변 텍스트 추가, 선택된 옵션 `✅` 마킹.
6
+ *
7
+ * pairing-question-extractor.ts의 QUESTION_LINE 정규식과 동일한
8
+ * 패턴을 사용하여 파싱/업데이트 간 일관성을 보장한다.
9
+ *
10
+ * @module
11
+ */
12
+ import type { PairingAnswer } from '../../types/index.js';
13
+ /** updatePlanQuestions 반환 타입 */
14
+ export interface PlanQuestionUpdateResult {
15
+ /** 업데이트된 계획서 전체 content */
16
+ updatedContent: string;
17
+ /** 실제 반영된 답변 수 */
18
+ appliedCount: number;
19
+ }
20
+ /**
21
+ * 계획서 content에서 페어링 질문 체크박스를 답변에 따라 업데이트한다
22
+ *
23
+ * 동작:
24
+ * 1. planContent에서 `## {pairingQuestionSection}` 섹션 시작/끝 라인 범위를 찾음
25
+ * 2. 섹션 내 각 질문 라인(`- [ ] **{id}**:`)을 순회
26
+ * 3. answers 배열에 해당 questionId가 있으면:
27
+ * - `[ ]` → `[x]`로 교체
28
+ * - 질문 라인 끝에 ` → **답변: {selectedOption 또는 freeText}**` 추가
29
+ * 4. 옵션 라인 중 선택된 옵션이 있으면 해당 라인에 `✅` 추가
30
+ * 5. answers에 없는 질문(건너뜀)은 원본 유지
31
+ * 6. 섹션 외 content는 그대로 보존
32
+ *
33
+ * @param planContent - 계획서 마크다운 전체 내용
34
+ * @param answers - CLI 프롬프트에서 수집된 답변 배열
35
+ * @param config - 페어링 질문 섹션명 설정
36
+ * @returns 업데이트된 content와 반영된 답변 수
37
+ */
38
+ export declare function updatePlanQuestions(planContent: string, answers: PairingAnswer[], config: {
39
+ pairingQuestionSection: string;
40
+ }): PlanQuestionUpdateResult;
41
+ //# sourceMappingURL=plan-question-updater.d.ts.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 계획서 페어링 질문 Writeback — Layer 1 순수 함수
3
+ *
4
+ * 페어링 질문에 대한 CLI 답변을 계획서 원본 마크다운에 반영한다.
5
+ * `[ ]` → `[x]` 체크, 답변 텍스트 추가, 선택된 옵션 `✅` 마킹.
6
+ *
7
+ * pairing-question-extractor.ts의 QUESTION_LINE 정규식과 동일한
8
+ * 패턴을 사용하여 파싱/업데이트 간 일관성을 보장한다.
9
+ *
10
+ * @module
11
+ */
12
+ import { findSectionLines, normalizeLineEndings } from '../parsers/md-utils.js';
13
+ /**
14
+ * 질문 라인 정규식 (pairing-question-extractor.ts와 동일 패턴)
15
+ *
16
+ * - checked: 체크 상태 ('x', 'X', 'v', 'V', 공백 등)
17
+ * - id: 질문 ID (예: "Q1")
18
+ * - text: 질문 본문
19
+ */
20
+ const QUESTION_LINE = /^(?<prefix>[-*]\s+)\[(?<checked>[xXvV ]*)\]\s+\*\*(?<id>[^*]+)\*\*\s*(?<sep>[:\-—]?)\s*(?<text>.+)$/;
21
+ /**
22
+ * 하위 옵션 라인 정규식 (들여쓰인 리스트 항목)
23
+ */
24
+ const SUB_OPTION_LINE = /^(?<indent>\s{2,})(?<bullet>[-*]\s+)(?<optionText>.+)$/;
25
+ /**
26
+ * 이미 체크된 상태인지 판별
27
+ */
28
+ function isChecked(mark) {
29
+ const lower = mark.toLowerCase();
30
+ return lower.includes('x') || lower.includes('v');
31
+ }
32
+ /**
33
+ * 계획서 content에서 페어링 질문 체크박스를 답변에 따라 업데이트한다
34
+ *
35
+ * 동작:
36
+ * 1. planContent에서 `## {pairingQuestionSection}` 섹션 시작/끝 라인 범위를 찾음
37
+ * 2. 섹션 내 각 질문 라인(`- [ ] **{id}**:`)을 순회
38
+ * 3. answers 배열에 해당 questionId가 있으면:
39
+ * - `[ ]` → `[x]`로 교체
40
+ * - 질문 라인 끝에 ` → **답변: {selectedOption 또는 freeText}**` 추가
41
+ * 4. 옵션 라인 중 선택된 옵션이 있으면 해당 라인에 `✅` 추가
42
+ * 5. answers에 없는 질문(건너뜀)은 원본 유지
43
+ * 6. 섹션 외 content는 그대로 보존
44
+ *
45
+ * @param planContent - 계획서 마크다운 전체 내용
46
+ * @param answers - CLI 프롬프트에서 수집된 답변 배열
47
+ * @param config - 페어링 질문 섹션명 설정
48
+ * @returns 업데이트된 content와 반영된 답변 수
49
+ */
50
+ export function updatePlanQuestions(planContent, answers, config) {
51
+ if (answers.length === 0) {
52
+ return { updatedContent: planContent, appliedCount: 0 };
53
+ }
54
+ const hasCRLF = planContent.includes('\r\n');
55
+ const normalized = normalizeLineEndings(planContent);
56
+ const sectionLines = findSectionLines(normalized, config.pairingQuestionSection);
57
+ if (sectionLines.length === 0) {
58
+ return { updatedContent: planContent, appliedCount: 0 };
59
+ }
60
+ const answerMap = new Map();
61
+ for (const a of answers) {
62
+ answerMap.set(a.questionId, a);
63
+ }
64
+ const lines = normalized.split('\n');
65
+ const { sectionStart, sectionEnd } = findSectionRange(lines, config.pairingQuestionSection);
66
+ if (sectionStart === -1) {
67
+ return { updatedContent: planContent, appliedCount: 0 };
68
+ }
69
+ let appliedCount = 0;
70
+ let currentAnswer;
71
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
72
+ const line = lines[i] ?? '';
73
+ const trimmed = line.trim();
74
+ if (trimmed === '')
75
+ continue;
76
+ const isTopLevel = !/^\s{2,}/.test(line);
77
+ if (isTopLevel) {
78
+ const qMatch = trimmed.match(QUESTION_LINE);
79
+ if (qMatch?.groups) {
80
+ const { checked, id } = qMatch.groups;
81
+ const questionId = (id ?? '').trim();
82
+ if (isChecked(checked ?? '')) {
83
+ currentAnswer = undefined;
84
+ continue;
85
+ }
86
+ const answer = answerMap.get(questionId);
87
+ if (answer) {
88
+ currentAnswer = answer;
89
+ const answerText = answer.selectedOption ?? answer.freeText ?? '';
90
+ const updatedLine = line.replace(/\[[ ]*\]/, '[x]').replace(/\s*$/, '');
91
+ lines[i] = `${updatedLine} → **답변: ${answerText}**`;
92
+ appliedCount++;
93
+ }
94
+ else {
95
+ currentAnswer = undefined;
96
+ }
97
+ continue;
98
+ }
99
+ }
100
+ if (currentAnswer?.selectedOption && /^\s{2,}/.test(line)) {
101
+ const optMatch = line.match(SUB_OPTION_LINE);
102
+ if (optMatch?.groups?.optionText) {
103
+ const rawText = optMatch.groups.optionText.trim();
104
+ const cleanText = rawText
105
+ .replace(/^\s*✅\s*/, '')
106
+ .replace(/\s*✅\s*$/, '')
107
+ .trim();
108
+ if (cleanText === currentAnswer.selectedOption && !rawText.includes('✅')) {
109
+ lines[i] = `${line} ✅`;
110
+ }
111
+ }
112
+ }
113
+ }
114
+ let result = lines.join('\n');
115
+ if (hasCRLF) {
116
+ result = result.replace(/\n/g, '\r\n');
117
+ }
118
+ return { updatedContent: result, appliedCount };
119
+ }
120
+ /**
121
+ * 섹션명에 해당하는 라인 범위(시작 헤딩 인덱스, 끝 인덱스)를 찾는다
122
+ *
123
+ * findSectionLines와 동일한 매칭 로직을 사용하되,
124
+ * 라인 인덱스 범위를 반환하여 원본 lines 배열을 직접 수정할 수 있게 한다.
125
+ */
126
+ function findSectionRange(lines, sectionName) {
127
+ let sectionStart = -1;
128
+ let sectionEnd = lines.length;
129
+ let sectionHeadingLevel = 0;
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const trimmed = (lines[i] ?? '').trim();
132
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
133
+ if (sectionStart !== -1 && headingMatch?.[1]) {
134
+ const level = headingMatch[1].length;
135
+ if (level <= sectionHeadingLevel) {
136
+ sectionEnd = i;
137
+ break;
138
+ }
139
+ }
140
+ if (sectionStart === -1 && headingMatch?.[2]) {
141
+ const headingText = headingMatch[2].trim();
142
+ if (headingText.includes(sectionName)) {
143
+ sectionStart = i;
144
+ sectionHeadingLevel = (headingMatch[1] ?? '').length;
145
+ }
146
+ }
147
+ }
148
+ return { sectionStart, sectionEnd };
149
+ }
150
+ //# sourceMappingURL=plan-question-updater.js.map
@@ -12,8 +12,10 @@ export declare const DEFAULT_ROADMAP_PATH = "vibe/roadmap.md";
12
12
  export declare const DEFAULT_PLAN_DIR = "vibe/unit-plans";
13
13
  export declare const DEFAULT_RESULT_DIR = "vibe/unit-results";
14
14
  export declare const DEFAULT_ID_PATTERN = "^(U-\\d+|CP-).*";
15
+ export declare const DEFAULT_RUNBOOK_DIR = "vibe/unit-runbooks";
15
16
  export declare const DEFAULT_COMMAND_SECTION = "# \uC720\uB2DB \uAD6C\uD604";
16
17
  export declare const DEFAULT_HEADER_TEMPLATE = "### \uD604\uC7AC \uAD6C\uD604 \uC720\uB2DB: {{title}}\n- \uD604\uC7AC \uAD6C\uD604 \uC720\uB2DB \uAC1C\uBC1C \uACC4\uD68D\uC11C: @{{planPath}}\n- \uD604\uC7AC \uAD6C\uD604 Commit(\uBCC0\uACBD\uC810 \uD655\uC778\uD558\uC5EC \uB9E5\uB77D\uC73C\uB85C \uC0AC\uC6A9): -";
18
+ export declare const SUB_UNIT_HEADER_TEMPLATE = "### \uD604\uC7AC \uC720\uB2DB: {{title}}\n- \uACC4\uD68D\uC11C: @{{planPath}}\n- Commit: -";
17
19
  /**
18
20
  * 외부 주입 데이터(stdin 등)에서 InitAnswers를 추출한다
19
21
  */
@@ -25,8 +27,8 @@ export declare function buildAnswersFromScan(scanResult: ScanResult, overrides?:
25
27
  /**
26
28
  * InitAnswers → 설정 JSON 객체 구성 (최소 필드)
27
29
  *
28
- * 함수는 Zod 스키마에 정의된 기본값들을 제외한 핵심 필드만으로 객체를 구성합니다.
29
- * 최종 결과는 Zod로 검증하거나 기존 설정과 병합하는 용도로 사용됩니다.
30
+ * scanResult가 제공되면 감지된 패턴별로 다중 unitType을 생성한다.
31
+ * 서브유닛 패턴(PREFIX-NNN-XN)이 감지된 prefix는 별도의 unitType으로 분리된다.
30
32
  */
31
- export declare function buildConfigObject(answers: InitAnswers): Record<string, unknown>;
33
+ export declare function buildConfigObject(answers: InitAnswers, scanResult?: ScanResult): Record<string, unknown>;
32
34
  //# sourceMappingURL=config-generator.d.ts.map
@@ -12,8 +12,10 @@ export const DEFAULT_ROADMAP_PATH = 'vibe/roadmap.md';
12
12
  export const DEFAULT_PLAN_DIR = 'vibe/unit-plans';
13
13
  export const DEFAULT_RESULT_DIR = 'vibe/unit-results';
14
14
  export const DEFAULT_ID_PATTERN = '^(U-\\d+|CP-).*';
15
+ export const DEFAULT_RUNBOOK_DIR = 'vibe/unit-runbooks';
15
16
  export const DEFAULT_COMMAND_SECTION = '# 유닛 구현';
16
17
  export const DEFAULT_HEADER_TEMPLATE = '### 현재 구현 유닛: {{title}}\n- 현재 구현 유닛 개발 계획서: @{{planPath}}\n- 현재 구현 Commit(변경점 확인하여 맥락으로 사용): -';
18
+ export const SUB_UNIT_HEADER_TEMPLATE = '### 현재 유닛: {{title}}\n- 계획서: @{{planPath}}\n- Commit: -';
17
19
  /**
18
20
  * Record<string, unknown> 오버라이드를 InitAnswers 기본값에 적용한다
19
21
  */
@@ -45,7 +47,7 @@ export function extractStdinAnswers(data) {
45
47
  roadmapPath: DEFAULT_ROADMAP_PATH,
46
48
  planDir: DEFAULT_PLAN_DIR,
47
49
  resultDir: DEFAULT_RESULT_DIR,
48
- runbookDir: null,
50
+ runbookDir: DEFAULT_RUNBOOK_DIR,
49
51
  idPattern: DEFAULT_ID_PATTERN,
50
52
  commandSection: DEFAULT_COMMAND_SECTION,
51
53
  };
@@ -77,10 +79,10 @@ export function buildAnswersFromScan(scanResult, overrides) {
77
79
  /**
78
80
  * InitAnswers → 설정 JSON 객체 구성 (최소 필드)
79
81
  *
80
- * 함수는 Zod 스키마에 정의된 기본값들을 제외한 핵심 필드만으로 객체를 구성합니다.
81
- * 최종 결과는 Zod로 검증하거나 기존 설정과 병합하는 용도로 사용됩니다.
82
+ * scanResult가 제공되면 감지된 패턴별로 다중 unitType을 생성한다.
83
+ * 서브유닛 패턴(PREFIX-NNN-XN)이 감지된 prefix는 별도의 unitType으로 분리된다.
82
84
  */
83
- export function buildConfigObject(answers) {
85
+ export function buildConfigObject(answers, scanResult) {
84
86
  const docRoots = {
85
87
  plan: answers.planDir,
86
88
  result: answers.resultDir,
@@ -88,7 +90,10 @@ export function buildConfigObject(answers) {
88
90
  if (answers.runbookDir) {
89
91
  docRoots['runbook'] = answers.runbookDir;
90
92
  }
91
- return {
93
+ const unitTypes = scanResult
94
+ ? buildUnitTypesFromScan(answers, scanResult, docRoots)
95
+ : [buildDefaultUnitType(answers)];
96
+ const config = {
92
97
  $schema: 'https://vibe-commander/config-schema.json',
93
98
  version: 1,
94
99
  paths: {
@@ -96,17 +101,80 @@ export function buildConfigObject(answers) {
96
101
  roadmap: answers.roadmapPath,
97
102
  docRoots,
98
103
  },
99
- unitTypes: [
100
- {
101
- key: 'implement',
102
- displayName: '유닛 구현',
103
- idPattern: answers.idPattern,
104
+ unitTypes,
105
+ };
106
+ if (unitTypes.length > 1) {
107
+ config['commandSections'] = { useMarkers: true };
108
+ }
109
+ return config;
110
+ }
111
+ function buildDefaultUnitType(answers) {
112
+ return {
113
+ key: 'implement',
114
+ displayName: '유닛 구현',
115
+ idPattern: answers.idPattern,
116
+ planDir: 'plan',
117
+ commandSection: answers.commandSection,
118
+ collectDeps: true,
119
+ headerTemplate: DEFAULT_HEADER_TEMPLATE,
120
+ };
121
+ }
122
+ /**
123
+ * 스캔 결과의 패턴 정보로 다중 unitType 배열을 구성한다.
124
+ *
125
+ * hasSubUnit이 true인 prefix는 서브유닛 타입 + 태그 타입으로 분리되고,
126
+ * 나머지 prefix는 하나의 implement 타입으로 통합된다.
127
+ */
128
+ function buildUnitTypesFromScan(answers, scanResult, docRoots) {
129
+ const unitTypes = [];
130
+ const implementPrefixes = [];
131
+ for (const pattern of scanResult.idPatterns) {
132
+ if (!pattern.hasSubUnit) {
133
+ implementPrefixes.push(pattern.regex.replace(/^\^/, ''));
134
+ continue;
135
+ }
136
+ const subDir = scanResult.docRoots.find((d) => d.role === 'refactorSub');
137
+ if (subDir) {
138
+ docRoots['refactorSub'] = subDir.dirPath;
139
+ }
140
+ unitTypes.push({
141
+ key: `${pattern.prefix.toLowerCase()}-sub`,
142
+ displayName: `${pattern.prefix} 서브유닛`,
143
+ idPattern: `^${pattern.prefix}-\\d+-[A-Z]\\d+$`,
144
+ planDir: subDir ? 'refactorSub' : 'plan',
145
+ commandSection: `# ${pattern.prefix} 서브유닛`,
146
+ collectDeps: false,
147
+ headerTemplate: SUB_UNIT_HEADER_TEMPLATE,
148
+ });
149
+ if (pattern.hasTag) {
150
+ unitTypes.push({
151
+ key: pattern.prefix.toLowerCase(),
152
+ displayName: `${pattern.prefix} 유닛`,
153
+ idPattern: `^${pattern.prefix}-\\d+\\[.*\\]$`,
104
154
  planDir: 'plan',
105
- commandSection: answers.commandSection,
155
+ commandSection: `# ${pattern.prefix} 유닛`,
106
156
  collectDeps: true,
107
- headerTemplate: DEFAULT_HEADER_TEMPLATE,
108
- },
109
- ],
110
- };
157
+ headerTemplate: SUB_UNIT_HEADER_TEMPLATE,
158
+ });
159
+ }
160
+ }
161
+ if (implementPrefixes.length > 0) {
162
+ const implPattern = implementPrefixes.length === 1
163
+ ? `^${implementPrefixes[0] ?? ''}.*`
164
+ : `^(${implementPrefixes.join('|')}).*`;
165
+ unitTypes.push({
166
+ key: 'implement',
167
+ displayName: '유닛 구현',
168
+ idPattern: implPattern,
169
+ planDir: 'plan',
170
+ commandSection: answers.commandSection,
171
+ collectDeps: true,
172
+ headerTemplate: DEFAULT_HEADER_TEMPLATE,
173
+ });
174
+ }
175
+ else if (unitTypes.length === 0) {
176
+ unitTypes.push(buildDefaultUnitType(answers));
177
+ }
178
+ return unitTypes;
111
179
  }
112
180
  //# sourceMappingURL=config-generator.js.map
@@ -53,7 +53,6 @@ function mergePaths(existing, scanned) {
53
53
  const existingDocRoots = isPlainObject(existing['docRoots']) ? existing['docRoots'] : {};
54
54
  const mergedDocRoots = { ...existingDocRoots };
55
55
  for (const key of Object.keys(scanned['docRoots'])) {
56
- // 스캔 결과에 있는 docRoots 키는 무조건 반영 (갱신 또는 추가)
57
56
  mergedDocRoots[key] = scanned['docRoots'][key];
58
57
  }
59
58
  merged['docRoots'] = mergedDocRoots;
@@ -22,6 +22,10 @@ export interface DetectedPattern {
22
22
  displayLabel: string;
23
23
  matchCount: number;
24
24
  examples: string[];
25
+ /** PREFIX-NNN[Tag] 형태의 태그 파일이 존재하는지 여부 */
26
+ hasTag: boolean;
27
+ /** PREFIX-NNN-XN 형태의 서브유닛 파일이 존재하는지 여부 */
28
+ hasSubUnit: boolean;
25
29
  }
26
30
  /** 감지된 문서 루트 디렉토리 */
27
31
  export interface DetectedDocRoot {
@@ -0,0 +1,53 @@
1
+ <!-- vc:begin:implement -->
2
+ # 유닛 구현
3
+ ---------------------------------------------------
4
+ ### 현재 구현 유닛: -
5
+ - 현재 구현 유닛 개발 계획서: -
6
+ - 현재 구현 Commit(변경점 확인하여 맥락으로 사용): -
7
+
8
+ ### 의존 유닛 및 기능:
9
+ -
10
+
11
+ ### 의존성 문서:
12
+ -
13
+
14
+ ### 의존성 Commits (변경점 확인하여 맥락으로 사용):
15
+ -
16
+
17
+ ### 특별 요청사항
18
+ -
19
+ ---------------------------------------------------
20
+ <!-- vc:end:implement -->
21
+
22
+ <!-- vc:begin:refactor -->
23
+ # 리펙토링 유닛 제안
24
+ ---------------------------------------------------
25
+ ### 현재 리펙토링 유닛: -
26
+ - 현재 리펙토링 유닛 제안 계획서: -
27
+ - 현재 리펙토링 Commit(변경점 확인하여 맥락으로 사용): -
28
+
29
+ ### 의존 유닛 및 기능:
30
+ -
31
+
32
+ ### 의존성 문서:
33
+ -
34
+
35
+ ### 의존성 Commits (변경점 확인하여 맥락으로 사용):
36
+ -
37
+
38
+ ### 특별 요청사항
39
+ -
40
+ ---------------------------------------------------
41
+ <!-- vc:end:refactor -->
42
+
43
+ <!-- vc:begin:refactor-sub -->
44
+ # 리펙토링 유닛 구현
45
+ ---------------------------------------------------
46
+ ### 현재 리펙토링 유닛: -
47
+ - 현재 리펙토링 유닛 제안 계획서: -
48
+ - 현재 리펙토링 Commit(변경점 확인하여 맥락으로 사용): -
49
+
50
+ ### 특별 요청사항
51
+ -
52
+ ---------------------------------------------------
53
+ <!-- vc:end:refactor-sub -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-commander",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Unit-based vibe coding workflow automation CLI — automate context collection from unit ID to command file in 10 seconds",
6
6
  "license": "MIT",