vibe-commander 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/dist/adapters/cli/commands/context-resolver.js +30 -0
- package/dist/adapters/cli/commands/list-units.d.ts +2 -3
- package/dist/adapters/cli/commands/list-units.js +8 -34
- package/dist/adapters/cli/commands/set-unit.d.ts +5 -0
- package/dist/adapters/cli/commands/set-unit.js +141 -8
- package/dist/adapters/cli/formatters-unit.js +4 -0
- package/dist/adapters/cli/output.js +3 -0
- package/dist/adapters/cli/router.d.ts +1 -0
- package/dist/adapters/cli/router.js +8 -4
- package/dist/adapters/cli/stdin-parser.js +15 -3
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +5 -0
- package/dist/core/parsers/dep-line-parser.js +7 -3
- package/dist/core/parsers/dependency-extractor.js +80 -14
- package/dist/core/parsers/index.d.ts +2 -1
- package/dist/core/parsers/index.js +3 -1
- package/dist/core/parsers/plan-parser-helpers.js +5 -0
- package/dist/core/parsers/subsection-extractor.d.ts +20 -0
- package/dist/core/parsers/subsection-extractor.js +56 -0
- package/dist/core/parsers/title-extractor.d.ts +16 -0
- package/dist/core/parsers/title-extractor.js +26 -0
- package/dist/core/renderers/index.d.ts +1 -0
- package/dist/core/renderers/section-renderer.d.ts +9 -1
- package/dist/core/renderers/section-renderer.js +29 -12
- package/dist/core/resolvers/backlog-resolver.d.ts +37 -0
- package/dist/core/resolvers/backlog-resolver.js +50 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -10,6 +10,8 @@ vc init # Initialize project (create config)
|
|
|
10
10
|
vc init --from-existing # Auto-detect from existing files
|
|
11
11
|
vc init --force # Overwrite existing config
|
|
12
12
|
vc set-unit <unitId> # Set current working unit
|
|
13
|
+
vc set-unit --next # Auto-select next ready unit from backlog
|
|
14
|
+
vc set-unit --next --dry-run # Preview auto-selected unit without modifying files
|
|
13
15
|
vc set-unit <unitId> --dry-run # Preview without modifying files
|
|
14
16
|
vc update-commit [sha|N] # Update commit SHA (no arg=HEAD, number=recent N)
|
|
15
17
|
vc list-units [--phase Mvp] # List incomplete units
|
|
@@ -43,6 +45,21 @@ vc set-unit U-013[Mvp] --json
|
|
|
43
45
|
vc update-commit --json
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
## Quick Set Workflow (--next)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1. Preview which unit will be auto-selected (no file changes)
|
|
52
|
+
vc set-unit --next --dry-run --json
|
|
53
|
+
|
|
54
|
+
# 2. Apply: auto-select next ready unit and configure command file
|
|
55
|
+
vc set-unit --next --json
|
|
56
|
+
|
|
57
|
+
# 3. Update commit SHA after making changes
|
|
58
|
+
vc update-commit --json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`--next` selects the first ready unit from the backlog (roadmap order). It is mutually exclusive with `<unitId>`. Pipe mode: `echo '{"next":true}' | vc set-unit --stdin --json`
|
|
62
|
+
|
|
46
63
|
## Config File
|
|
47
64
|
|
|
48
65
|
- Location: `vibe-commander.config.json` in project root
|
|
@@ -13,6 +13,7 @@ import { ok, fail } from '../../../types/index.js';
|
|
|
13
13
|
import { loadConfig } from '../../../config/loader.js';
|
|
14
14
|
import { resolveUnitType, locatePlan } from '../../../config/resolver.js';
|
|
15
15
|
import { parseUnitPlan } from '../../../core/parsers/plan-parser.js';
|
|
16
|
+
import { extractTitleFromContent } from '../../../core/parsers/title-extractor.js';
|
|
16
17
|
import { resolveDepDocs } from '../../../core/resolvers/dep-doc-resolver.js';
|
|
17
18
|
import { resolveActualDocs, collectGitCommits, buildSpecialRequests } from './io-helpers.js';
|
|
18
19
|
/**
|
|
@@ -68,6 +69,8 @@ export function resolveCommandContext(unitId, projectRoot, requestKeys) {
|
|
|
68
69
|
}
|
|
69
70
|
// 6b. Git 커밋 수집 (Adapter I/O)
|
|
70
71
|
depCommits = collectGitCommits(depIds, config.commitSearch, projectRoot);
|
|
72
|
+
// 6c. description이 빈 의존 유닛에 대해 의존 계획서 H1 title Fallback
|
|
73
|
+
enrichEmptyDescriptions(meta.depends, config, projectRoot);
|
|
71
74
|
}
|
|
72
75
|
// 7. 특별 요청사항 조합
|
|
73
76
|
const srResult = buildSpecialRequests(config, unitTypeConfig.key, requestKeys);
|
|
@@ -90,4 +93,31 @@ export function resolveCommandContext(unitId, projectRoot, requestKeys) {
|
|
|
90
93
|
warnings,
|
|
91
94
|
});
|
|
92
95
|
}
|
|
96
|
+
// ── description Fallback 헬퍼 ──
|
|
97
|
+
/**
|
|
98
|
+
* description이 빈 의존 유닛에 대해 의존 계획서 파일의 H1 title로 보강한다
|
|
99
|
+
*
|
|
100
|
+
* Graceful Degradation: 파일 미존재, 읽기 실패, title 추출 실패 시 빈 상태 유지.
|
|
101
|
+
* 기존에 description이 채워져 있으면 절대 덮어쓰지 않는다.
|
|
102
|
+
*/
|
|
103
|
+
function enrichEmptyDescriptions(depends, config, projectRoot) {
|
|
104
|
+
for (const dep of depends) {
|
|
105
|
+
if (dep.description !== '')
|
|
106
|
+
continue;
|
|
107
|
+
const planResult = locatePlan(dep.unitId, config);
|
|
108
|
+
if (!planResult.success)
|
|
109
|
+
continue;
|
|
110
|
+
const depPlanPath = join(projectRoot, planResult.data);
|
|
111
|
+
try {
|
|
112
|
+
const content = readFileSync(depPlanPath, 'utf-8');
|
|
113
|
+
const titleOnly = extractTitleFromContent(content);
|
|
114
|
+
if (titleOnly) {
|
|
115
|
+
dep.description = titleOnly;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Graceful Degradation: 파일 미존재 시 빈 description 유지
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
93
123
|
//# sourceMappingURL=context-resolver.js.map
|
|
@@ -27,9 +27,8 @@ export interface ListUnitsResult {
|
|
|
27
27
|
* 2. 로드맵 경로 확인 — null이면 에러 반환
|
|
28
28
|
* 3. 로드맵 파일 읽기 (Adapter I/O)
|
|
29
29
|
* 4. 백로그 파싱 (parseBacklog)
|
|
30
|
-
* 5.
|
|
31
|
-
* 6.
|
|
32
|
-
* 7. 결과 반환
|
|
30
|
+
* 5. 백로그 분류 및 필터링 (classifyBacklog)
|
|
31
|
+
* 6. 결과 반환
|
|
33
32
|
*
|
|
34
33
|
* @param args - 파싱된 CLI 인자 (command === 'list-units' 보장)
|
|
35
34
|
* @param projectRoot - 프로젝트 루트 절대 경로
|
|
@@ -14,6 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { ok, fail } from '../../../types/index.js';
|
|
15
15
|
import { loadConfig } from '../../../config/loader.js';
|
|
16
16
|
import { parseBacklog } from '../../../core/parsers/backlog-parser.js';
|
|
17
|
+
import { classifyBacklog } from '../../../core/resolvers/backlog-resolver.js';
|
|
17
18
|
/**
|
|
18
19
|
* list-units 커맨드를 실행한다
|
|
19
20
|
*
|
|
@@ -22,9 +23,8 @@ import { parseBacklog } from '../../../core/parsers/backlog-parser.js';
|
|
|
22
23
|
* 2. 로드맵 경로 확인 — null이면 에러 반환
|
|
23
24
|
* 3. 로드맵 파일 읽기 (Adapter I/O)
|
|
24
25
|
* 4. 백로그 파싱 (parseBacklog)
|
|
25
|
-
* 5.
|
|
26
|
-
* 6.
|
|
27
|
-
* 7. 결과 반환
|
|
26
|
+
* 5. 백로그 분류 및 필터링 (classifyBacklog)
|
|
27
|
+
* 6. 결과 반환
|
|
28
28
|
*
|
|
29
29
|
* @param args - 파싱된 CLI 인자 (command === 'list-units' 보장)
|
|
30
30
|
* @param projectRoot - 프로젝트 루트 절대 경로
|
|
@@ -57,36 +57,10 @@ export function handleListUnits(args, projectRoot) {
|
|
|
57
57
|
const parseResult = parseBacklog(roadmapContent, config.backlogParsing);
|
|
58
58
|
if (!parseResult.success)
|
|
59
59
|
return parseResult;
|
|
60
|
-
// 5.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
entries = entries.filter((e) => {
|
|
66
|
-
const entryPhase = extractPhase(e.unitId);
|
|
67
|
-
return entryPhase !== null && entryPhase.toLowerCase() === normalizedPhase;
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
// 7. 상태별 분류
|
|
71
|
-
const inProgress = entries.filter((e) => e.status === 'inProgress');
|
|
72
|
-
const ready = entries.filter((e) => e.status === 'ready');
|
|
73
|
-
const blocked = entries.filter((e) => e.status === 'blocked');
|
|
74
|
-
return ok({
|
|
75
|
-
total: entries.length,
|
|
76
|
-
inProgress,
|
|
77
|
-
ready,
|
|
78
|
-
blocked,
|
|
79
|
-
...(phase !== undefined && { phase }),
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* 유닛 ID에서 phase 태그를 추출한다
|
|
84
|
-
*
|
|
85
|
-
* "U-016[Mvp]" → "Mvp"
|
|
86
|
-
* "CP-MVP-03" → null
|
|
87
|
-
*/
|
|
88
|
-
function extractPhase(unitId) {
|
|
89
|
-
const match = unitId.match(/\[([^\]]+)\]$/);
|
|
90
|
-
return match?.[1] ?? null;
|
|
60
|
+
// 5. 백로그 분류 및 필터링 (Core 순수 함수)
|
|
61
|
+
const classificationResult = classifyBacklog(parseResult.data, { phase });
|
|
62
|
+
if (!classificationResult.success)
|
|
63
|
+
return classificationResult;
|
|
64
|
+
return ok(classificationResult.data);
|
|
91
65
|
}
|
|
92
66
|
//# sourceMappingURL=list-units.js.map
|
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
|
-
import { writeFileSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
14
15
|
import { ok, fail } from '../../../types/index.js';
|
|
15
16
|
import { validateUnitId } from '../../../core/validators/input-validator.js';
|
|
17
|
+
import { parseBacklog } from '../../../core/parsers/backlog-parser.js';
|
|
18
|
+
import { classifyBacklog } from '../../../core/resolvers/backlog-resolver.js';
|
|
16
19
|
import { renderSection } from '../../../core/renderers/section-renderer.js';
|
|
17
20
|
import { updateSection } from '../../../core/renderers/section-updater.js';
|
|
21
|
+
import { findMarkerRange } from '../../../core/renderers/marker-utils.js';
|
|
22
|
+
import { extractSubsectionContent } from '../../../core/parsers/subsection-extractor.js';
|
|
23
|
+
import { normalizeLineEndings } from '../../../core/parsers/md-utils.js';
|
|
24
|
+
import { loadConfig } from '../../../config/loader.js';
|
|
18
25
|
import { resolveCommandContext } from './context-resolver.js';
|
|
19
26
|
import { readCommandsFile, enrichWithSuggestions } from './io-helpers.js';
|
|
20
27
|
import { writeActiveUnitType } from './state-helpers.js';
|
|
@@ -37,9 +44,21 @@ export async function handleSetUnit(args, projectRoot) {
|
|
|
37
44
|
if (args.command !== 'set-unit') {
|
|
38
45
|
return fail('INTERNAL_ERROR', 'handleSetUnit에 잘못된 커맨드가 전달되었습니다');
|
|
39
46
|
}
|
|
40
|
-
const {
|
|
47
|
+
const { noPrompt, dryRun, next } = args;
|
|
48
|
+
let unitId = args.unitId;
|
|
41
49
|
const requestKeys = 'requestKeys' in args ? args.requestKeys : [];
|
|
42
|
-
|
|
50
|
+
let autoSelected = false;
|
|
51
|
+
let selectedFrom;
|
|
52
|
+
// 0-a. --next: 백로그에서 ready 유닛 자동 선택
|
|
53
|
+
if (next) {
|
|
54
|
+
const pickResult = pickNextUnit(projectRoot);
|
|
55
|
+
if (!pickResult.success)
|
|
56
|
+
return pickResult;
|
|
57
|
+
unitId = pickResult.data.unitId;
|
|
58
|
+
autoSelected = true;
|
|
59
|
+
selectedFrom = { ready: pickResult.data.readyCount, total: pickResult.data.totalCount };
|
|
60
|
+
}
|
|
61
|
+
// 0-b. 입력 안전성 검증 (제어 문자, 경로 순회, URL 인코딩 등)
|
|
43
62
|
const safetyCheck = validateUnitId(unitId);
|
|
44
63
|
if (!safetyCheck.valid) {
|
|
45
64
|
return fail('INVALID_INPUT', safetyCheck.error ?? '입력 검증 실패');
|
|
@@ -57,12 +76,25 @@ export async function handleSetUnit(args, projectRoot) {
|
|
|
57
76
|
pairingAnswers = await promptAllQuestions(context.pairingQuestions);
|
|
58
77
|
context.pairingAnswers = pairingAnswers;
|
|
59
78
|
}
|
|
60
|
-
// 3.
|
|
61
|
-
const
|
|
62
|
-
// 4.
|
|
79
|
+
// 3. 커맨드 파일 읽기 (보존 추출 + 업데이트에 재사용)
|
|
80
|
+
const cmdFile = readCommandsFile(config, projectRoot);
|
|
81
|
+
// 4. 기존 특별 요청사항 추출 (보존 로직)
|
|
82
|
+
const specialRequestsHeading = config.commandSections.specialRequestsHeading;
|
|
83
|
+
let preservedSpecialRequests;
|
|
84
|
+
if (cmdFile.success) {
|
|
85
|
+
const extracted = extractExistingSpecialRequests(cmdFile.data.content, unitTypeConfig.commandSection, specialRequestsHeading, config.commandSections.useMarkers ? unitTypeConfig.key : undefined, config.commandSections.separator);
|
|
86
|
+
if (extracted) {
|
|
87
|
+
preservedSpecialRequests = extracted;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 5. 섹션 렌더링 (Core 순수 함수)
|
|
91
|
+
const sectionBody = renderSection(context, unitTypeConfig, undefined, {
|
|
92
|
+
preservedSpecialRequests,
|
|
93
|
+
specialRequestsHeading,
|
|
94
|
+
});
|
|
95
|
+
// 6. 커맨드 파일 업데이트 (Adapter I/O) — dry-run 시 건너뜀
|
|
63
96
|
let commandsUpdated = false;
|
|
64
97
|
if (!dryRun) {
|
|
65
|
-
const cmdFile = readCommandsFile(config, projectRoot);
|
|
66
98
|
if (cmdFile.success) {
|
|
67
99
|
const useMarkers = config.commandSections.useMarkers;
|
|
68
100
|
const markerOptions = useMarkers ? { sectionKey: unitTypeConfig.key } : undefined;
|
|
@@ -84,7 +116,7 @@ export async function handleSetUnit(args, projectRoot) {
|
|
|
84
116
|
else {
|
|
85
117
|
warnings.push(`커맨드 파일을 읽을 수 없습니다: ${config.paths.commands}`);
|
|
86
118
|
}
|
|
87
|
-
//
|
|
119
|
+
// 7. 활성 유닛 타입 상태 기록 (update-commit 자동 감지용)
|
|
88
120
|
writeActiveUnitType(projectRoot, unitTypeKey);
|
|
89
121
|
}
|
|
90
122
|
// 6. 결과 반환
|
|
@@ -101,6 +133,107 @@ export async function handleSetUnit(args, projectRoot) {
|
|
|
101
133
|
commandsUpdated,
|
|
102
134
|
warnings,
|
|
103
135
|
...(dryRun && { dryRun: true, preview: sectionBody }),
|
|
136
|
+
...(autoSelected && { autoSelected, selectedFrom }),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 백로그에서 첫 번째 ready 유닛을 선택한다
|
|
141
|
+
*
|
|
142
|
+
* loadConfig → 로드맵 확인 → parseBacklog → ready 필터 → 첫 번째 반환
|
|
143
|
+
*/
|
|
144
|
+
function pickNextUnit(projectRoot) {
|
|
145
|
+
const configResult = loadConfig(projectRoot);
|
|
146
|
+
if (!configResult.success)
|
|
147
|
+
return configResult;
|
|
148
|
+
const config = configResult.data;
|
|
149
|
+
if (!config.paths.roadmap) {
|
|
150
|
+
return fail('ROADMAP_NOT_CONFIGURED', '--next 옵션을 사용하려면 로드맵 파일이 설정되어야 합니다', 'vibe-commander.config.json의 paths.roadmap을 설정하거나, vc set-unit <unitId>로 직접 지정하세요');
|
|
151
|
+
}
|
|
152
|
+
const roadmapAbsPath = join(projectRoot, config.paths.roadmap);
|
|
153
|
+
let roadmapContent;
|
|
154
|
+
try {
|
|
155
|
+
roadmapContent = readFileSync(roadmapAbsPath, 'utf-8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return fail('ROADMAP_NOT_FOUND', `로드맵 파일을 찾을 수 없습니다: ${config.paths.roadmap}`, `절대 경로: ${roadmapAbsPath}`);
|
|
159
|
+
}
|
|
160
|
+
const parseResult = parseBacklog(roadmapContent, config.backlogParsing);
|
|
161
|
+
if (!parseResult.success)
|
|
162
|
+
return parseResult;
|
|
163
|
+
// 백로그 분류 (Core 순수 함수)
|
|
164
|
+
const classificationResult = classifyBacklog(parseResult.data);
|
|
165
|
+
if (!classificationResult.success)
|
|
166
|
+
return classificationResult;
|
|
167
|
+
const { ready, inProgress, blocked, total } = classificationResult.data;
|
|
168
|
+
if (ready.length === 0) {
|
|
169
|
+
return fail('NO_READY_UNITS', '착수 가능한(ready) 유닛이 없습니다', `현재 상태: 진행중 ${String(inProgress.length)}개, 블록됨 ${String(blocked.length)}개. vc list-units로 확인하세요`);
|
|
170
|
+
}
|
|
171
|
+
const first = ready[0];
|
|
172
|
+
if (!first) {
|
|
173
|
+
return fail('NO_READY_UNITS', '착수 가능한(ready) 유닛이 없습니다');
|
|
174
|
+
}
|
|
175
|
+
return ok({
|
|
176
|
+
unitId: first.unitId,
|
|
177
|
+
readyCount: ready.length,
|
|
178
|
+
totalCount: total,
|
|
104
179
|
});
|
|
105
180
|
}
|
|
181
|
+
// ── 특별 요청사항 보존 헬퍼 ──
|
|
182
|
+
/**
|
|
183
|
+
* 커맨드 파일에서 대상 섹션 내 기존 특별 요청사항 콘텐츠를 추출한다
|
|
184
|
+
*
|
|
185
|
+
* Fallback 체인:
|
|
186
|
+
* 1. 마커 기반: findMarkerRange로 섹션 범위 확보 → 내부에서 서브섹션 추출
|
|
187
|
+
* 2. 헤더 기반: commandSection 헤더로 섹션 범위 확보 → 내부에서 서브섹션 추출
|
|
188
|
+
* 3. 둘 다 실패: null 반환 (기존 동작 Fallback)
|
|
189
|
+
*
|
|
190
|
+
* 추출된 콘텐츠에서 trailing separator는 제거하여 이중 separator를 방지한다.
|
|
191
|
+
*/
|
|
192
|
+
function extractExistingSpecialRequests(fileContent, sectionHeader, specialRequestsHeading, sectionKey, separator) {
|
|
193
|
+
const lines = normalizeLineEndings(fileContent).split('\n');
|
|
194
|
+
let raw = null;
|
|
195
|
+
// 1. 마커 기반 범위 탐색
|
|
196
|
+
if (sectionKey) {
|
|
197
|
+
const range = findMarkerRange(lines, sectionKey);
|
|
198
|
+
if (range) {
|
|
199
|
+
const sectionContent = lines.slice(range.start + 1, range.end).join('\n');
|
|
200
|
+
raw = extractSubsectionContent(sectionContent, specialRequestsHeading);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// 2. 헤더 기반 Fallback
|
|
204
|
+
if (raw === null) {
|
|
205
|
+
const needle = sectionHeader.trim();
|
|
206
|
+
const headerIdx = lines.findIndex((l) => l.trim() === needle);
|
|
207
|
+
if (headerIdx === -1)
|
|
208
|
+
return null;
|
|
209
|
+
const hashMatch = needle.match(/^(#+)/);
|
|
210
|
+
const level = hashMatch?.[1] ? hashMatch[1].length : 1;
|
|
211
|
+
let endIdx = lines.length;
|
|
212
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
213
|
+
const line = (lines[i] ?? '').trim();
|
|
214
|
+
const lineMatch = line.match(/^(#+)\s/);
|
|
215
|
+
if (lineMatch?.[1] && lineMatch[1].length <= level) {
|
|
216
|
+
endIdx = i;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const sectionContent = lines.slice(headerIdx, endIdx).join('\n');
|
|
221
|
+
raw = extractSubsectionContent(sectionContent, specialRequestsHeading);
|
|
222
|
+
}
|
|
223
|
+
if (!raw)
|
|
224
|
+
return null;
|
|
225
|
+
// trailing separator 제거 (updateSection이 별도 삽입하므로 이중 방지)
|
|
226
|
+
if (separator) {
|
|
227
|
+
const resultLines = raw.split('\n');
|
|
228
|
+
const sep = separator.trim();
|
|
229
|
+
while (resultLines.length > 0 && (resultLines[resultLines.length - 1] ?? '').trim() === sep) {
|
|
230
|
+
resultLines.pop();
|
|
231
|
+
}
|
|
232
|
+
while (resultLines.length > 0 && (resultLines[resultLines.length - 1] ?? '').trim() === '') {
|
|
233
|
+
resultLines.pop();
|
|
234
|
+
}
|
|
235
|
+
return resultLines.length > 0 ? resultLines.join('\n') : null;
|
|
236
|
+
}
|
|
237
|
+
return raw;
|
|
238
|
+
}
|
|
106
239
|
//# sourceMappingURL=set-unit.js.map
|
|
@@ -19,9 +19,13 @@ export function formatSetUnit(result, json) {
|
|
|
19
19
|
const data = result.data;
|
|
20
20
|
if (!isSetUnitResult(data))
|
|
21
21
|
return;
|
|
22
|
+
const autoData = data;
|
|
22
23
|
console.log(`
|
|
23
24
|
${GREEN}✅ 작업 유닛 설정 완료: ${data.unitId}${RESET}
|
|
24
25
|
`);
|
|
26
|
+
if (autoData.autoSelected && autoData.selectedFrom) {
|
|
27
|
+
console.log(`${CYAN}🎯 자동 선택됨${RESET} ${DIM}(ready ${String(autoData.selectedFrom.ready)}개 중 첫 번째)${RESET}`);
|
|
28
|
+
}
|
|
25
29
|
console.log(`${BOLD}📝 제목${RESET}: ${data.title}`);
|
|
26
30
|
console.log(`${BOLD}📂 유형${RESET}: ${data.unitType}`);
|
|
27
31
|
console.log(`${BOLD}📄 계획서${RESET}: ${data.planPath}`);
|
|
@@ -100,6 +100,7 @@ ${YELLOW}글로벌 옵션:${RESET}
|
|
|
100
100
|
${CYAN}--stdin${RESET} stdin에서 JSON 입력 읽기
|
|
101
101
|
|
|
102
102
|
${YELLOW}set-unit 옵션:${RESET}
|
|
103
|
+
${CYAN}--next${RESET} 백로그에서 ready 유닛을 자동 선택
|
|
103
104
|
${CYAN}--request${RESET} <key1,key2> 커스텀 특별 요청사항 키 선택 (콤마 구분)
|
|
104
105
|
${CYAN}--no-prompt${RESET} 페어링 질문 인터랙티브 프롬프트 비활성화
|
|
105
106
|
${CYAN}--dry-run${RESET} 커맨드 파일 수정 없이 미리보기만 출력
|
|
@@ -126,6 +127,8 @@ ${YELLOW}예시:${RESET}
|
|
|
126
127
|
${DIM}$ vc init${RESET}
|
|
127
128
|
${DIM}$ vc init --from-existing${RESET} ${DIM}# 기존 파일 스캔으로 자동 설정${RESET}
|
|
128
129
|
${DIM}$ vc set-unit U-013[Mvp]${RESET}
|
|
130
|
+
${DIM}$ vc set-unit --next${RESET} ${DIM}# 다음 ready 유닛 자동 선택${RESET}
|
|
131
|
+
${DIM}$ vc set-unit --next --dry-run${RESET} ${DIM}# 자동 선택 미리보기만${RESET}
|
|
129
132
|
${DIM}$ vc set-unit U-013[Mvp] --dry-run${RESET} ${DIM}# 커맨드 파일 미수정, 미리보기만${RESET}
|
|
130
133
|
${DIM}$ vc set-unit U-013[Mvp] --dry-run --json${RESET} ${DIM}# 미리보기를 JSON으로 출력${RESET}
|
|
131
134
|
${DIM}$ vc set-unit U-013[Mvp] --request mcp-nanobanana,mcp-codrag${RESET}
|
|
@@ -68,12 +68,16 @@ export function parseArgs(argv) {
|
|
|
68
68
|
/**
|
|
69
69
|
* set-unit 서브커맨드의 인자를 파싱한다
|
|
70
70
|
*
|
|
71
|
-
* 필수: <unitId>
|
|
71
|
+
* 필수: <unitId> 또는 --next (상호 배타)
|
|
72
72
|
*/
|
|
73
73
|
function parseSetUnit(args, json) {
|
|
74
|
+
const next = hasFlag(args, '--next');
|
|
74
75
|
const unitId = getPositionalArg(args);
|
|
75
|
-
if (
|
|
76
|
-
return fail('
|
|
76
|
+
if (next && unitId) {
|
|
77
|
+
return fail('CONFLICTING_OPTIONS', '--next와 <unitId>는 동시에 사용할 수 없습니다', '사용법: vc set-unit --next 또는 vc set-unit <unitId>');
|
|
78
|
+
}
|
|
79
|
+
if (!next && !unitId) {
|
|
80
|
+
return fail('MISSING_ARGUMENT', "'set-unit' 명령어에 <unitId> 인자 또는 --next 옵션이 필요합니다", '사용법: vc set-unit <unitId> 또는 vc set-unit --next');
|
|
77
81
|
}
|
|
78
82
|
const requestRaw = getOptionValue(args, '--request');
|
|
79
83
|
const requestKeys = requestRaw
|
|
@@ -86,7 +90,7 @@ function parseSetUnit(args, json) {
|
|
|
86
90
|
const dryRun = hasFlag(args, '--dry-run');
|
|
87
91
|
return {
|
|
88
92
|
success: true,
|
|
89
|
-
data: { command: 'set-unit', unitId, requestKeys, noPrompt, dryRun, json },
|
|
93
|
+
data: { command: 'set-unit', unitId: unitId ?? '', next, requestKeys, noPrompt, dryRun, json },
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
/**
|
|
@@ -21,15 +21,27 @@ import { ok, fail } from '../../types/index.js';
|
|
|
21
21
|
export function parseStdinArgs(subcommand, data, json, rawArgs) {
|
|
22
22
|
switch (subcommand) {
|
|
23
23
|
case 'set-unit': {
|
|
24
|
+
const next = data.next === true;
|
|
24
25
|
const unitId = typeof data.unitId === 'string' ? data.unitId : undefined;
|
|
25
|
-
if (
|
|
26
|
-
return fail('
|
|
26
|
+
if (next && unitId) {
|
|
27
|
+
return fail('CONFLICTING_OPTIONS', '"next"와 "unitId"는 동시에 사용할 수 없습니다', '예시: {"next": true} 또는 {"unitId": "U-001[Mvp]"}');
|
|
28
|
+
}
|
|
29
|
+
if (!next && !unitId) {
|
|
30
|
+
return fail('STDIN_MISSING_FIELD', 'stdin JSON에 "unitId" (string) 또는 "next": true 필드가 필요합니다', '예시: {"unitId": "U-001[Mvp]"} 또는 {"next": true}');
|
|
27
31
|
}
|
|
28
32
|
const requestKeys = Array.isArray(data.requestKeys)
|
|
29
33
|
? data.requestKeys.filter((k) => typeof k === 'string')
|
|
30
34
|
: [];
|
|
31
35
|
const dryRun = rawArgs?.includes('--dry-run') ?? false;
|
|
32
|
-
return ok({
|
|
36
|
+
return ok({
|
|
37
|
+
command: 'set-unit',
|
|
38
|
+
unitId: unitId ?? '',
|
|
39
|
+
next,
|
|
40
|
+
requestKeys,
|
|
41
|
+
noPrompt: true,
|
|
42
|
+
dryRun,
|
|
43
|
+
json,
|
|
44
|
+
});
|
|
33
45
|
}
|
|
34
46
|
case 'update-commit': {
|
|
35
47
|
const modeValue = typeof data.mode === 'string' ? data.mode : undefined;
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ export declare const commandSectionsSchema: z.ZodObject<{
|
|
|
93
93
|
preserveOtherSections: z.ZodDefault<z.ZodBoolean>;
|
|
94
94
|
commitFieldName: z.ZodDefault<z.ZodString>;
|
|
95
95
|
useMarkers: z.ZodDefault<z.ZodBoolean>;
|
|
96
|
+
specialRequestsHeading: z.ZodDefault<z.ZodString>;
|
|
96
97
|
}, z.core.$strip>;
|
|
97
98
|
/** 기본 문서 탐색 설정 */
|
|
98
99
|
export declare const DEFAULT_DOC_DISCOVERY: {
|
|
@@ -173,6 +174,7 @@ export declare const projectConfigSchema: z.ZodObject<{
|
|
|
173
174
|
preserveOtherSections: z.ZodDefault<z.ZodBoolean>;
|
|
174
175
|
commitFieldName: z.ZodDefault<z.ZodString>;
|
|
175
176
|
useMarkers: z.ZodDefault<z.ZodBoolean>;
|
|
177
|
+
specialRequestsHeading: z.ZodDefault<z.ZodString>;
|
|
176
178
|
}, z.core.$strip>>;
|
|
177
179
|
defaultSpecialRequests: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
178
180
|
specialRequestsByType: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
|
package/dist/config/schema.js
CHANGED
|
@@ -227,6 +227,11 @@ export const commandSectionsSchema = z.object({
|
|
|
227
227
|
.boolean()
|
|
228
228
|
.default(false)
|
|
229
229
|
.describe('Use HTML comment markers (<!-- vc:begin/end -->) for safer section boundary detection'),
|
|
230
|
+
/** 특별 요청사항 섹션 헤딩 (기본: "### 특별 요청사항") */
|
|
231
|
+
specialRequestsHeading: z
|
|
232
|
+
.string()
|
|
233
|
+
.default('### 특별 요청사항')
|
|
234
|
+
.describe('Sub-heading for special requests in the command file'),
|
|
230
235
|
});
|
|
231
236
|
// ── 최상위 설정 ──
|
|
232
237
|
/** 기본 문서 탐색 설정 */
|
|
@@ -115,7 +115,7 @@ function extractDepFromLine(line, idPattern) {
|
|
|
115
115
|
}
|
|
116
116
|
if (candidates.length === 0)
|
|
117
117
|
return null;
|
|
118
|
-
// idPattern으로
|
|
118
|
+
// idPattern으로 필터링: 볼드 헤더(`**헤더**: [ID](url)`)에서 비유닛 볼드를 건너뜀
|
|
119
119
|
const idRegex = idPattern ? safeRegex(idPattern) : null;
|
|
120
120
|
const validCandidate = idRegex ? candidates.find((c) => idRegex.test(c.id)) : candidates[0];
|
|
121
121
|
if (!validCandidate)
|
|
@@ -150,8 +150,12 @@ function extractDescription(content, unitId, source) {
|
|
|
150
150
|
const afterMatch = content.match(afterPattern);
|
|
151
151
|
if (!afterMatch?.[1])
|
|
152
152
|
return '';
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
let rest = afterMatch[1].trim();
|
|
154
|
+
// 구분자 제거: 시작 부분의 :, — (공백 유연) 또는 — , - (공백 필수)
|
|
155
|
+
// : -10% 같은 경우 :만 제거하고 -10%는 보존하기 위해 순차적으로 처리
|
|
156
|
+
rest = rest.replace(/^[:—]\s*/, '');
|
|
157
|
+
rest = rest.replace(/^[—-]\s+/, '');
|
|
158
|
+
return rest.trim();
|
|
155
159
|
}
|
|
156
160
|
/**
|
|
157
161
|
* 하위 항목에서 artifact 텍스트를 추출한다
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { ok } from '../../types/index.js';
|
|
18
18
|
import { parseMetadataTable } from './metadata-parser.js';
|
|
19
|
-
import { FRONTMATTER_RE, findSectionLines, deduplicateBy, normalizeLineEndings, isNoneDependency, } from './md-utils.js';
|
|
19
|
+
import { FRONTMATTER_RE, findSectionLines, deduplicateBy, normalizeLineEndings, isNoneDependency, escapeRegex, } from './md-utils.js';
|
|
20
20
|
import { parseDependencyLines } from './dep-line-parser.js';
|
|
21
21
|
/**
|
|
22
22
|
* 계획서에서 의존 유닛 정보를 추출한다
|
|
@@ -79,10 +79,7 @@ export function extractDependenciesWithNotes(content, config, idPattern) {
|
|
|
79
79
|
}
|
|
80
80
|
// 2. Fallback: 메타데이터 테이블 (설정 소스가 metadata-table이면 이미 시도했으므로 건너뜀)
|
|
81
81
|
if (config.dependsSource !== 'metadata-table') {
|
|
82
|
-
const
|
|
83
|
-
const fromTable = fromTableResult.success
|
|
84
|
-
? fromTableResult.data.depends.map((unitId) => ({ unitId, description: '' }))
|
|
85
|
-
: [];
|
|
82
|
+
const fromTable = extractFromMetadataTable(normalized, config.metadataTable);
|
|
86
83
|
if (fromTable.length > 0) {
|
|
87
84
|
return ok({
|
|
88
85
|
deps: deduplicateBy(fromTable, (d) => d.unitId),
|
|
@@ -161,33 +158,102 @@ function extractFromFrontmatter(content) {
|
|
|
161
158
|
}
|
|
162
159
|
return [];
|
|
163
160
|
}
|
|
164
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* 메타데이터 테이블에서 의존성을 추출한다
|
|
163
|
+
*
|
|
164
|
+
* raw 값에 구분자(`—`, `:`, `-`)로 설명이 포함된 경우:
|
|
165
|
+
* `| 의존성 | U-101 — Token 발급 |` → `{ unitId: "U-101", description: "Token 발급" }`
|
|
166
|
+
*
|
|
167
|
+
* parseMetadataTable은 콤마 분리 후 전체 토큰을 unitId로 반환하므로,
|
|
168
|
+
* 여기서 unitId와 description을 분리한다.
|
|
169
|
+
*/
|
|
165
170
|
function extractFromMetadataTable(content, tableConfig) {
|
|
166
171
|
const tableResult = parseMetadataTable(content, tableConfig);
|
|
167
172
|
if (!tableResult.success)
|
|
168
173
|
return [];
|
|
169
|
-
return tableResult.data.depends.map((
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
return tableResult.data.depends.map((rawToken) => splitUnitIdAndDescription(rawToken));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 구분자 패턴:
|
|
178
|
+
* 1. 콜론(:) 또는 em dash(—)는 공백 유연하게 허용
|
|
179
|
+
* 2. 하이픈(-)은 유닛 ID와 혼동될 수 있으므로 반드시 앞뒤 공백 필요
|
|
180
|
+
*/
|
|
181
|
+
const DESC_SEPARATOR_RE = /^(?:(.+?)\s*([—:])\s*(.+)|(.+?)\s+-\s+(.+))$/;
|
|
182
|
+
/**
|
|
183
|
+
* `U-101 — Token 발급` 같은 토큰에서 unitId와 description을 분리한다
|
|
184
|
+
*
|
|
185
|
+
* 구분자가 없으면 전체가 unitId, description은 빈 문자열.
|
|
186
|
+
*/
|
|
187
|
+
function splitUnitIdAndDescription(rawToken) {
|
|
188
|
+
const match = rawToken.match(DESC_SEPARATOR_RE);
|
|
189
|
+
if (match) {
|
|
190
|
+
// 1번 그룹(ID), 3번 그룹(Desc) 또는 4번 그룹(ID), 5번 그룹(Desc)
|
|
191
|
+
const unitId = (match[1] || match[4] || '').trim();
|
|
192
|
+
const description = (match[3] || match[5] || '').trim();
|
|
193
|
+
if (unitId && description) {
|
|
194
|
+
return { unitId, description };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { unitId: rawToken, description: '' };
|
|
173
198
|
}
|
|
174
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* 본문 전체에서 ID 패턴으로 의존성을 스캔한다 (Fallback 최후 수단)
|
|
201
|
+
*
|
|
202
|
+
* ID가 발견된 줄에서 구분자(`—`, `:`, `-`) 이후 텍스트를 description으로 추출 시도.
|
|
203
|
+
* 같은 줄에 설명이 없으면 다음 줄이 하위 항목인지 확인하여 추출.
|
|
204
|
+
*/
|
|
175
205
|
function extractFromBodyScan(content, idPattern) {
|
|
176
206
|
try {
|
|
177
207
|
const regex = new RegExp(idPattern, 'g');
|
|
178
208
|
const matches = content.match(regex);
|
|
179
209
|
if (!matches)
|
|
180
210
|
return [];
|
|
181
|
-
// 중복 제거
|
|
182
211
|
const unique = [...new Set(matches)];
|
|
212
|
+
const lines = content.split('\n');
|
|
183
213
|
return unique.map((unitId) => ({
|
|
184
214
|
unitId,
|
|
185
|
-
description:
|
|
215
|
+
description: extractDescriptionFromLines(lines, unitId),
|
|
186
216
|
}));
|
|
187
217
|
}
|
|
188
218
|
catch {
|
|
189
|
-
// 잘못된 정규식 — 빈 배열 반환 (Graceful Degradation)
|
|
190
219
|
return [];
|
|
191
220
|
}
|
|
192
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* 본문 라인에서 unitId가 포함된 첫 번째 줄에서 description을 추출한다
|
|
224
|
+
*
|
|
225
|
+
* `**U-002**: 공유 타입`, `[U-002](url) — 설명`, `U-002의 ...` 패턴에서
|
|
226
|
+
* 구분자 이후 텍스트를 description으로 반환.
|
|
227
|
+
* 같은 줄에 설명이 없으면 다음 줄의 텍스트를 확인.
|
|
228
|
+
*/
|
|
229
|
+
function extractDescriptionFromLines(lines, unitId) {
|
|
230
|
+
const escapedId = escapeRegex(unitId);
|
|
231
|
+
const linePattern = new RegExp(escapedId);
|
|
232
|
+
for (let i = 0; i < lines.length; i++) {
|
|
233
|
+
const line = lines[i] ?? '';
|
|
234
|
+
if (!linePattern.test(line))
|
|
235
|
+
continue;
|
|
236
|
+
// 1. 같은 줄에서 추출 시도
|
|
237
|
+
// 링크 형식: [ID](url) 뒤의 텍스트
|
|
238
|
+
const linkDesc = new RegExp(`\\[${escapedId}\\]\\([^)]*\\)\\s*[—:\\-]\\s*(.+)`);
|
|
239
|
+
const linkMatch = line.match(linkDesc);
|
|
240
|
+
if (linkMatch?.[1])
|
|
241
|
+
return linkMatch[1].trim();
|
|
242
|
+
// 볼드 형식: **ID** 뒤의 텍스트
|
|
243
|
+
const boldDesc = new RegExp(`\\*\\*${escapedId}\\*\\*\\s*[—:\\-]\\s*(.+)`);
|
|
244
|
+
const boldMatch = line.match(boldDesc);
|
|
245
|
+
if (boldMatch?.[1])
|
|
246
|
+
return boldMatch[1].trim();
|
|
247
|
+
// 2. 같은 줄에 구분자 설명이 없으면 다음 줄 확인
|
|
248
|
+
if (i + 1 < lines.length) {
|
|
249
|
+
const nextLine = (lines[i + 1] ?? '').trim();
|
|
250
|
+
// 다음 줄이 비어있지 않고, 다른 리스트 항목이나 헤더가 아니면 설명으로 간주
|
|
251
|
+
if (nextLine !== '' && !/^[-*#]/.test(nextLine)) {
|
|
252
|
+
return nextLine;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
193
259
|
//# sourceMappingURL=dependency-extractor.js.map
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
export { parseUnitPlan } from './plan-parser.js';
|
|
10
10
|
export type { ParseUnitPlanResult } from './plan-parser.js';
|
|
11
11
|
export { parseBacklog } from './backlog-parser.js';
|
|
12
|
-
export {
|
|
12
|
+
export { extractSubsectionContent } from './subsection-extractor.js';
|
|
13
|
+
export { extractTitle, extractTitleFromContent } from './title-extractor.js';
|
|
13
14
|
export { parseMetadataTable } from './metadata-parser.js';
|
|
14
15
|
export type { MetadataTableResult } from './metadata-parser.js';
|
|
15
16
|
export { extractDependencies, extractDependenciesWithNotes } from './dependency-extractor.js';
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
export { parseUnitPlan } from './plan-parser.js';
|
|
11
11
|
// 백로그 파서
|
|
12
12
|
export { parseBacklog } from './backlog-parser.js';
|
|
13
|
+
// 서브섹션 추출기
|
|
14
|
+
export { extractSubsectionContent } from './subsection-extractor.js';
|
|
13
15
|
// 개별 추출기 (필요 시 단독 사용)
|
|
14
|
-
export { extractTitle } from './title-extractor.js';
|
|
16
|
+
export { extractTitle, extractTitleFromContent } from './title-extractor.js';
|
|
15
17
|
export { parseMetadataTable } from './metadata-parser.js';
|
|
16
18
|
export { extractDependencies, extractDependenciesWithNotes } from './dependency-extractor.js';
|
|
17
19
|
export { extractPairingQuestions } from './pairing-question-extractor.js';
|
|
@@ -61,6 +61,11 @@ export function extractPhase(content, config) {
|
|
|
61
61
|
export function extractDependsWithWarning(content, config, idPattern, warnings) {
|
|
62
62
|
const result = extractDependenciesWithNotes(content, config, idPattern);
|
|
63
63
|
if (result.success) {
|
|
64
|
+
// description이 빈 항목이 있으면 보강 힌트 추가
|
|
65
|
+
const emptyDescCount = result.data.deps.filter((d) => d.description === '').length;
|
|
66
|
+
if (emptyDescCount > 0) {
|
|
67
|
+
warnings.push(`일부 의존 유닛의 설명이 비어 있습니다 (${String(emptyDescCount)}개). 의존 계획서 파일의 제목에서 설명을 추출합니다.`);
|
|
68
|
+
}
|
|
64
69
|
return { depends: result.data.deps, contextNotes: result.data.contextNotes };
|
|
65
70
|
}
|
|
66
71
|
// 의존성 추출 실패 — 빈 결과 + 경고
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서브섹션 추출기 — Layer 1 순수 함수
|
|
3
|
+
*
|
|
4
|
+
* 섹션 본문에서 특정 `###` 서브섹션 콘텐츠를 추출한다.
|
|
5
|
+
* `renderSection`의 보존 로직에서 기존 특별 요청사항을 추출하는 데 사용.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 콘텐츠에서 지정된 서브헤딩의 콘텐츠를 추출한다
|
|
11
|
+
*
|
|
12
|
+
* 서브헤딩 라인 자체도 결과에 포함된다.
|
|
13
|
+
* 다음 동급 이상(### 이하 레벨) 헤딩 또는 콘텐츠 끝까지 추출한다.
|
|
14
|
+
*
|
|
15
|
+
* @param content - 검색 대상 콘텐츠
|
|
16
|
+
* @param subHeading - 찾을 서브헤딩 텍스트 (예: "### 특별 요청사항")
|
|
17
|
+
* @returns 서브헤딩 포함 콘텐츠 또는 null (미발견/빈 콘텐츠)
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractSubsectionContent(content: string, subHeading: string): string | null;
|
|
20
|
+
//# sourceMappingURL=subsection-extractor.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서브섹션 추출기 — Layer 1 순수 함수
|
|
3
|
+
*
|
|
4
|
+
* 섹션 본문에서 특정 `###` 서브섹션 콘텐츠를 추출한다.
|
|
5
|
+
* `renderSection`의 보존 로직에서 기존 특별 요청사항을 추출하는 데 사용.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeLineEndings } from './md-utils.js';
|
|
10
|
+
/**
|
|
11
|
+
* 콘텐츠에서 지정된 서브헤딩의 콘텐츠를 추출한다
|
|
12
|
+
*
|
|
13
|
+
* 서브헤딩 라인 자체도 결과에 포함된다.
|
|
14
|
+
* 다음 동급 이상(### 이하 레벨) 헤딩 또는 콘텐츠 끝까지 추출한다.
|
|
15
|
+
*
|
|
16
|
+
* @param content - 검색 대상 콘텐츠
|
|
17
|
+
* @param subHeading - 찾을 서브헤딩 텍스트 (예: "### 특별 요청사항")
|
|
18
|
+
* @returns 서브헤딩 포함 콘텐츠 또는 null (미발견/빈 콘텐츠)
|
|
19
|
+
*/
|
|
20
|
+
export function extractSubsectionContent(content, subHeading) {
|
|
21
|
+
if (!content.trim() || !subHeading.trim())
|
|
22
|
+
return null;
|
|
23
|
+
const normalized = normalizeLineEndings(content);
|
|
24
|
+
const lines = normalized.split('\n');
|
|
25
|
+
const needle = subHeading.trim();
|
|
26
|
+
const hashMatch = needle.match(/^(#+)\s/);
|
|
27
|
+
if (!hashMatch?.[1])
|
|
28
|
+
return null;
|
|
29
|
+
const level = hashMatch[1].length;
|
|
30
|
+
let startIdx = -1;
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
if ((lines[i] ?? '').trim() === needle) {
|
|
33
|
+
startIdx = i;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (startIdx === -1)
|
|
38
|
+
return null;
|
|
39
|
+
let endIdx = lines.length;
|
|
40
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
41
|
+
const line = (lines[i] ?? '').trim();
|
|
42
|
+
const lineHashMatch = line.match(/^(#+)\s/);
|
|
43
|
+
if (lineHashMatch?.[1] && lineHashMatch[1].length <= level) {
|
|
44
|
+
endIdx = i;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const extracted = lines.slice(startIdx, endIdx);
|
|
49
|
+
while (extracted.length > 0 && (extracted[extracted.length - 1] ?? '').trim() === '') {
|
|
50
|
+
extracted.pop();
|
|
51
|
+
}
|
|
52
|
+
if (extracted.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
return extracted.join('\n');
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=subsection-extractor.js.map
|
|
@@ -33,4 +33,20 @@ import type { ToolResult } from '../../types/index.js';
|
|
|
33
33
|
* ```
|
|
34
34
|
*/
|
|
35
35
|
export declare function extractTitle(content: string, titleSource: string): ToolResult<string | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* 마크다운 콘텐츠에서 H1 제목의 순수 작업명을 추출한다
|
|
38
|
+
*
|
|
39
|
+
* H1 형식 `# U-101[Mmp]: Ephemeral Token 발급`에서
|
|
40
|
+
* unitId prefix(`U-101[Mmp]:`)를 제거하고 순수 작업명만 반환.
|
|
41
|
+
*
|
|
42
|
+
* 지원 형식:
|
|
43
|
+
* - `# U-101[Mmp]: 작업명` → `작업명`
|
|
44
|
+
* - `# U-101: 작업명` → `작업명`
|
|
45
|
+
* - `# 작업명만` → `작업명만` (unitId prefix 없는 경우)
|
|
46
|
+
* - `# U-101[Mmp]: 작업명 — 부제목` → `작업명 — 부제목`
|
|
47
|
+
*
|
|
48
|
+
* @param content - 마크다운 전체 내용
|
|
49
|
+
* @returns 순수 작업명 문자열 (H1이 없으면 빈 문자열)
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractTitleFromContent(content: string): string;
|
|
36
52
|
//# sourceMappingURL=title-extractor.d.ts.map
|
|
@@ -45,6 +45,32 @@ export function extractTitle(content, titleSource) {
|
|
|
45
45
|
// 알 수 없는 소스 — undefined 반환 (Graceful Degradation)
|
|
46
46
|
return ok(undefined);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 마크다운 콘텐츠에서 H1 제목의 순수 작업명을 추출한다
|
|
50
|
+
*
|
|
51
|
+
* H1 형식 `# U-101[Mmp]: Ephemeral Token 발급`에서
|
|
52
|
+
* unitId prefix(`U-101[Mmp]:`)를 제거하고 순수 작업명만 반환.
|
|
53
|
+
*
|
|
54
|
+
* 지원 형식:
|
|
55
|
+
* - `# U-101[Mmp]: 작업명` → `작업명`
|
|
56
|
+
* - `# U-101: 작업명` → `작업명`
|
|
57
|
+
* - `# 작업명만` → `작업명만` (unitId prefix 없는 경우)
|
|
58
|
+
* - `# U-101[Mmp]: 작업명 — 부제목` → `작업명 — 부제목`
|
|
59
|
+
*
|
|
60
|
+
* @param content - 마크다운 전체 내용
|
|
61
|
+
* @returns 순수 작업명 문자열 (H1이 없으면 빈 문자열)
|
|
62
|
+
*/
|
|
63
|
+
export function extractTitleFromContent(content) {
|
|
64
|
+
const normalized = normalizeLineEndings(content);
|
|
65
|
+
const h1 = extractH1Title(normalized);
|
|
66
|
+
if (!h1)
|
|
67
|
+
return '';
|
|
68
|
+
// `ID: 설명` 또는 `ID — 설명` 패턴에서 설명 부분만 추출
|
|
69
|
+
const prefixMatch = h1.match(/^[A-Za-z]+-\d+(?:-\w+)?(?:\[[^\]]*\])?\s*[:—-]\s*(.+)/);
|
|
70
|
+
if (prefixMatch?.[1])
|
|
71
|
+
return prefixMatch[1].trim();
|
|
72
|
+
return h1;
|
|
73
|
+
}
|
|
48
74
|
// ── 내부 헬퍼 ──
|
|
49
75
|
/**
|
|
50
76
|
* 첫 번째 H1 헤더에서 제목을 추출한다
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export { toShortId, toBareId, toSlug, buildTemplateVars, interpolatePattern, } from './interpolate.js';
|
|
9
9
|
export { extractTitleOnly, buildSectionVars, interpolateTemplate } from './template-engine.js';
|
|
10
10
|
export { renderSection } from './section-renderer.js';
|
|
11
|
+
export type { RenderSectionOptions } from './section-renderer.js';
|
|
11
12
|
export { updateSection, updateField } from './section-updater.js';
|
|
12
13
|
export type { UpdateResult, MarkerOptions } from './section-updater.js';
|
|
13
14
|
export { createBeginMarker, createEndMarker, findMarkerRange, isValidSectionKey, wrapWithMarkers, stripMarkers, } from './marker-utils.js';
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* @module
|
|
13
13
|
*/
|
|
14
14
|
import type { CommandContext, UnitTypeConfig } from '../../types/index.js';
|
|
15
|
+
/** renderSection 옵션 */
|
|
16
|
+
export interface RenderSectionOptions {
|
|
17
|
+
/** 보존할 기존 특별 요청사항 콘텐츠 (헤딩 포함). truthy이면 설정 기반 렌더링 대신 사용 */
|
|
18
|
+
preservedSpecialRequests?: string;
|
|
19
|
+
/** 특별 요청사항 섹션 헤딩 (기본: "### 특별 요청사항") */
|
|
20
|
+
specialRequestsHeading?: string;
|
|
21
|
+
}
|
|
15
22
|
/**
|
|
16
23
|
* 커맨드 파일 섹션을 렌더링한다
|
|
17
24
|
*
|
|
@@ -25,7 +32,8 @@ import type { CommandContext, UnitTypeConfig } from '../../types/index.js';
|
|
|
25
32
|
* @param context - 렌더링할 전체 컨텍스트 (유닛 메타 + 의존성 + 특별 요청)
|
|
26
33
|
* @param unitTypeConfig - 유닛 유형 설정 (headerTemplate, collectDeps 등)
|
|
27
34
|
* @param docPrefix - 문서 경로 앞에 붙일 프리픽스 (기본: "@")
|
|
35
|
+
* @param options - 추가 렌더링 옵션 (보존 콘텐츠, 커스텀 헤딩 등)
|
|
28
36
|
* @returns 렌더링된 섹션 본문 문자열
|
|
29
37
|
*/
|
|
30
|
-
export declare function renderSection(context: CommandContext, unitTypeConfig: UnitTypeConfig, docPrefix?: string): string;
|
|
38
|
+
export declare function renderSection(context: CommandContext, unitTypeConfig: UnitTypeConfig, docPrefix?: string, options?: RenderSectionOptions): string;
|
|
31
39
|
//# sourceMappingURL=section-renderer.d.ts.map
|
|
@@ -16,6 +16,8 @@ import { interpolateTemplate } from './template-engine.js';
|
|
|
16
16
|
const DEFAULT_DOC_PREFIX = '@';
|
|
17
17
|
/** 빈 항목 표시 마커 */
|
|
18
18
|
const EMPTY_MARKER = '-';
|
|
19
|
+
/** 기본 특별 요청사항 헤딩 */
|
|
20
|
+
const DEFAULT_SPECIAL_REQUESTS_HEADING = '### 특별 요청사항';
|
|
19
21
|
/**
|
|
20
22
|
* 커맨드 파일 섹션을 렌더링한다
|
|
21
23
|
*
|
|
@@ -29,9 +31,10 @@ const EMPTY_MARKER = '-';
|
|
|
29
31
|
* @param context - 렌더링할 전체 컨텍스트 (유닛 메타 + 의존성 + 특별 요청)
|
|
30
32
|
* @param unitTypeConfig - 유닛 유형 설정 (headerTemplate, collectDeps 등)
|
|
31
33
|
* @param docPrefix - 문서 경로 앞에 붙일 프리픽스 (기본: "@")
|
|
34
|
+
* @param options - 추가 렌더링 옵션 (보존 콘텐츠, 커스텀 헤딩 등)
|
|
32
35
|
* @returns 렌더링된 섹션 본문 문자열
|
|
33
36
|
*/
|
|
34
|
-
export function renderSection(context, unitTypeConfig, docPrefix = DEFAULT_DOC_PREFIX) {
|
|
37
|
+
export function renderSection(context, unitTypeConfig, docPrefix = DEFAULT_DOC_PREFIX, options) {
|
|
35
38
|
const lines = [];
|
|
36
39
|
// 1. Header block (headerTemplate 변수 보간)
|
|
37
40
|
const header = interpolateTemplate(unitTypeConfig.headerTemplate, context.unit);
|
|
@@ -42,8 +45,14 @@ export function renderSection(context, unitTypeConfig, docPrefix = DEFAULT_DOC_P
|
|
|
42
45
|
renderDepDocs(lines, context, docPrefix);
|
|
43
46
|
renderDepCommits(lines, context);
|
|
44
47
|
}
|
|
45
|
-
// 3. 특별
|
|
46
|
-
|
|
48
|
+
// 3. 특별 요청사항: 보존 콘텐츠가 있으면 그대로 사용, 없으면 설정에서 렌더링
|
|
49
|
+
if (options?.preservedSpecialRequests) {
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(...options.preservedSpecialRequests.split('\n'));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
renderSpecialRequests(lines, context, options?.specialRequestsHeading);
|
|
55
|
+
}
|
|
47
56
|
return lines.join('\n');
|
|
48
57
|
}
|
|
49
58
|
/**
|
|
@@ -70,17 +79,24 @@ function renderDepUnits(lines, context) {
|
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
/**
|
|
73
|
-
* 의존성 문서 경로를 렌더링한다
|
|
82
|
+
* 의존성 문서 경로를 유닛별로 그룹핑하여 렌더링한다
|
|
74
83
|
*
|
|
75
|
-
*
|
|
84
|
+
* 각 의존 유닛의 계획서, 결과서, 런북 3종을 `- @{path}` 형식으로 한줄씩 렌더링.
|
|
85
|
+
* 유닛이 여러 개일 때 유닛 ID 주석으로 그룹 구분.
|
|
76
86
|
*/
|
|
77
87
|
function renderDepDocs(lines, context, docPrefix) {
|
|
78
88
|
lines.push('');
|
|
79
89
|
lines.push('### 의존성 문서:');
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
const nonEmptyGroups = context.depDocs.filter((d) => d.found.length > 0);
|
|
91
|
+
if (nonEmptyGroups.length > 0) {
|
|
92
|
+
const useGroupHeaders = nonEmptyGroups.length > 1;
|
|
93
|
+
for (const group of nonEmptyGroups) {
|
|
94
|
+
if (useGroupHeaders) {
|
|
95
|
+
lines.push(`<!-- ${group.unitId} -->`);
|
|
96
|
+
}
|
|
97
|
+
for (const doc of group.found) {
|
|
98
|
+
lines.push(`- ${docPrefix}${doc.path}`);
|
|
99
|
+
}
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
else {
|
|
@@ -91,10 +107,11 @@ function renderDepDocs(lines, context, docPrefix) {
|
|
|
91
107
|
* 의존성 커밋 SHA를 렌더링한다
|
|
92
108
|
*
|
|
93
109
|
* 형식: `- {sha}`
|
|
110
|
+
* 헤딩에 "(변경점 확인하여 맥락으로 사용)" 주석 포함
|
|
94
111
|
*/
|
|
95
112
|
function renderDepCommits(lines, context) {
|
|
96
113
|
lines.push('');
|
|
97
|
-
lines.push('### 의존성 Commits:');
|
|
114
|
+
lines.push('### 의존성 Commits (변경점 확인하여 맥락으로 사용):');
|
|
98
115
|
if (context.depCommits.length > 0) {
|
|
99
116
|
for (const sha of context.depCommits) {
|
|
100
117
|
lines.push(`- ${sha}`);
|
|
@@ -113,9 +130,9 @@ function renderDepCommits(lines, context) {
|
|
|
113
130
|
* 모든 미결정 질문이 답변되었으면 자동으로 ✅를 추가한다.
|
|
114
131
|
* 답변된 페어링 질문은 특별 요청사항 뒤에 체크리스트 형태로 렌더링.
|
|
115
132
|
*/
|
|
116
|
-
function renderSpecialRequests(lines, context) {
|
|
133
|
+
function renderSpecialRequests(lines, context, heading) {
|
|
117
134
|
lines.push('');
|
|
118
|
-
lines.push(
|
|
135
|
+
lines.push(heading ?? DEFAULT_SPECIAL_REQUESTS_HEADING);
|
|
119
136
|
const allAnswered = areAllQuestionsAnswered(context);
|
|
120
137
|
if (context.specialRequests.length > 0) {
|
|
121
138
|
for (const req of context.specialRequests) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 백로그 분류 리졸버 — Layer 1 (Core)
|
|
3
|
+
*
|
|
4
|
+
* 파싱된 백로그 항목들을 필터링하고 상태별(ready/inProgress/blocked)로 분류한다.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import type { BacklogEntry, ToolResult } from '../../types/index.js';
|
|
9
|
+
/** 백로그 분류 옵션 */
|
|
10
|
+
export interface BacklogFilterOptions {
|
|
11
|
+
phase?: string;
|
|
12
|
+
excludeCompleted?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** 백로그 분류 결과 */
|
|
15
|
+
export interface ClassifiedBacklog {
|
|
16
|
+
total: number;
|
|
17
|
+
inProgress: BacklogEntry[];
|
|
18
|
+
ready: BacklogEntry[];
|
|
19
|
+
blocked: BacklogEntry[];
|
|
20
|
+
phase?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 백로그 항목들을 분류한다 (Pure Function)
|
|
24
|
+
*
|
|
25
|
+
* @param entries - 파싱된 전체 백로그 항목
|
|
26
|
+
* @param options - 필터링 옵션
|
|
27
|
+
* @returns 분류된 결과
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifyBacklog(entries: BacklogEntry[], options?: BacklogFilterOptions): ToolResult<ClassifiedBacklog>;
|
|
30
|
+
/**
|
|
31
|
+
* 유닛 ID에서 phase 태그를 추출한다 (Pure Function)
|
|
32
|
+
*
|
|
33
|
+
* "U-016[Mvp]" → "Mvp"
|
|
34
|
+
* "CP-MVP-03" → null
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractPhaseFromId(unitId: string): string | null;
|
|
37
|
+
//# sourceMappingURL=backlog-resolver.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 백로그 분류 리졸버 — Layer 1 (Core)
|
|
3
|
+
*
|
|
4
|
+
* 파싱된 백로그 항목들을 필터링하고 상태별(ready/inProgress/blocked)로 분류한다.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { ok } from '../../types/index.js';
|
|
9
|
+
/**
|
|
10
|
+
* 백로그 항목들을 분류한다 (Pure Function)
|
|
11
|
+
*
|
|
12
|
+
* @param entries - 파싱된 전체 백로그 항목
|
|
13
|
+
* @param options - 필터링 옵션
|
|
14
|
+
* @returns 분류된 결과
|
|
15
|
+
*/
|
|
16
|
+
export function classifyBacklog(entries, options = {}) {
|
|
17
|
+
const { phase, excludeCompleted = true } = options;
|
|
18
|
+
// 1. 완료 항목 제외 (옵션에 따라)
|
|
19
|
+
let activeEntries = excludeCompleted ? entries.filter((e) => e.status !== 'completed') : entries;
|
|
20
|
+
// 2. phase 필터링 (대소문자 무시)
|
|
21
|
+
if (phase) {
|
|
22
|
+
const normalizedPhase = phase.toLowerCase();
|
|
23
|
+
activeEntries = activeEntries.filter((e) => {
|
|
24
|
+
const entryPhase = extractPhaseFromId(e.unitId);
|
|
25
|
+
return entryPhase !== null && entryPhase.toLowerCase() === normalizedPhase;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// 3. 상태별 분류
|
|
29
|
+
const inProgress = activeEntries.filter((e) => e.status === 'inProgress');
|
|
30
|
+
const ready = activeEntries.filter((e) => e.status === 'ready');
|
|
31
|
+
const blocked = activeEntries.filter((e) => e.status === 'blocked');
|
|
32
|
+
return ok({
|
|
33
|
+
total: activeEntries.length,
|
|
34
|
+
inProgress,
|
|
35
|
+
ready,
|
|
36
|
+
blocked,
|
|
37
|
+
...(phase !== undefined && { phase }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 유닛 ID에서 phase 태그를 추출한다 (Pure Function)
|
|
42
|
+
*
|
|
43
|
+
* "U-016[Mvp]" → "Mvp"
|
|
44
|
+
* "CP-MVP-03" → null
|
|
45
|
+
*/
|
|
46
|
+
export function extractPhaseFromId(unitId) {
|
|
47
|
+
const match = unitId.match(/\[([^\]]+)\]$/);
|
|
48
|
+
return match?.[1] ?? null;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=backlog-resolver.js.map
|
package/package.json
CHANGED