deuk-agent-flow 4.2.7 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ko.md +259 -0
- package/CHANGELOG.md +769 -124
- package/LICENSE +0 -0
- package/README.ko.md +97 -17
- package/README.md +108 -25
- package/bin/deuk-agent-flow.js +11 -13
- package/bin/deuk-agent-rule.js +1 -1
- package/bundled/README.md +3 -0
- package/bundled/deuk-agent-flow.vsix +0 -0
- package/core-rules/AGENTS.md +30 -120
- package/docs/architecture.ko.md +155 -2
- package/docs/architecture.md +155 -2
- package/docs/assets/agentflow-panel-skills.png +0 -0
- package/docs/assets/agentflow-panel.png +0 -0
- package/docs/how-it-works.ko.md +109 -52
- package/docs/how-it-works.md +128 -71
- package/docs/principles.ko.md +68 -68
- package/docs/principles.md +68 -68
- package/docs/usage-guide.ko.md +251 -212
- package/package.json +42 -45
- package/scripts/bundle-vscode-vsix.ts +67 -0
- package/scripts/{cli-args.mjs → cli-args.ts} +49 -12
- package/scripts/cli-init-commands.ts +99 -0
- package/scripts/cli-init-logic.ts +46 -0
- package/scripts/{cli-prompts.mjs → cli-prompts.ts} +19 -34
- package/scripts/{cli-rule-compiler.mjs → cli-rule-compiler.ts} +128 -112
- package/scripts/cli-skill-commands.ts +707 -0
- package/scripts/{cli-telemetry-commands.mjs → cli-telemetry-commands.ts} +25 -22
- package/scripts/cli-ticket-command-shared.ts +4 -0
- package/scripts/cli-ticket-commands.ts +3723 -0
- package/scripts/cli-ticket-index.ts +283 -0
- package/scripts/{cli-ticket-migration.mjs → cli-ticket-migration.ts} +44 -68
- package/scripts/cli-ticket-parser.ts +100 -0
- package/scripts/{cli-usage-commands.mjs → cli-usage-commands.ts} +325 -326
- package/scripts/cli-utils.ts +1560 -0
- package/scripts/cli.ts +695 -0
- package/scripts/{lint-md.mjs → lint-md.ts} +32 -42
- package/scripts/lint-rules.ts +203 -0
- package/scripts/{merge-logic.mjs → merge-logic.ts} +44 -44
- package/scripts/{plan-parser.mjs → plan-parser.ts} +53 -53
- package/templates/MODULE_RULE_TEMPLATE.md +11 -11
- package/templates/PROJECT_RULE.md +46 -47
- package/templates/TICKET_TEMPLATE.ko.md +48 -44
- package/templates/TICKET_TEMPLATE.md +48 -44
- package/templates/project-memory.md +19 -0
- package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +25 -25
- package/templates/project-pilot/DRIFT_CHECKLIST.md +27 -27
- package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -26
- package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -30
- package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -26
- package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -15
- package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -34
- package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -32
- package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -33
- package/templates/rules.d/deukcontext-mcp.md +31 -31
- package/templates/rules.d/platform-coexistence.md +29 -29
- package/templates/skills/context-recall/SKILL.md +3 -1
- package/templates/skills/doc-sync/SKILL.md +111 -0
- package/templates/skills/generated-file-guard/SKILL.md +3 -1
- package/templates/skills/persona-maid/SKILL.md +65 -0
- package/templates/skills/project-pilot/SKILL.md +14 -63
- package/templates/skills/safe-refactor/SKILL.md +3 -1
- package/templates/skills/ticket-status-surface/SKILL.md +17 -0
- package/NOTICE.md +0 -19
- package/core-rules/GEMINI.md +0 -7
- package/docs/npm-publish-guide.ko.md +0 -70
- package/scripts/cli-init-commands.mjs +0 -1759
- package/scripts/cli-init-logic.mjs +0 -64
- package/scripts/cli-skill-commands.mjs +0 -212
- package/scripts/cli-ticket-command-shared.mjs +0 -60
- package/scripts/cli-ticket-commands.mjs +0 -2474
- package/scripts/cli-ticket-index.mjs +0 -322
- package/scripts/cli-ticket-parser.mjs +0 -210
- package/scripts/cli-utils.mjs +0 -602
- package/scripts/cli.mjs +0 -256
- package/scripts/lint-rules.mjs +0 -197
- package/scripts/publish-dual-npm.mjs +0 -141
- package/scripts/smoke-npm-docker.mjs +0 -102
- package/scripts/smoke-npm-local.mjs +0 -110
- package/scripts/update-download-badge.mjs +0 -103
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "fs";
|
|
2
|
+
import { basename, dirname, resolve } from "path";
|
|
3
|
+
import {
|
|
4
|
+
DEUK_ROOT_DIR, TICKET_SUBDIR, TICKET_INDEX_FILENAME,
|
|
5
|
+
requireNonEmptySlug, findFileRecursively, toRepoRelativePath, detectConsumerTicketDir, computeTicketPath, normalizeTicketGroup, writeFileLF, makePath, resolveWorkspaceContext
|
|
6
|
+
, CliOpts } from "./cli-utils.js";
|
|
7
|
+
import { collectTicketEntriesFromTopicFiles } from "./cli-ticket-scan.js";
|
|
8
|
+
|
|
9
|
+
const ARCHIVE_INDEX_MONTH_RE = /^INDEX\.archive\.(\d{4}-\d{2})\.json$/;
|
|
10
|
+
const ARCHIVE_INDEX_LEGACY_RE = /^INDEX\.archive\.json$/;
|
|
11
|
+
|
|
12
|
+
function parseNextTicketSequence(value) {
|
|
13
|
+
const raw = Number(value);
|
|
14
|
+
if (!Number.isInteger(raw) || raw < 1) return null;
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function maxTicketNumberFromEntries(entries = []) {
|
|
19
|
+
const newRe = /^([0-9]{3,4})-/;
|
|
20
|
+
let max = 0;
|
|
21
|
+
for (const e of (entries || [])) {
|
|
22
|
+
const id = String(e.id || '');
|
|
23
|
+
const m = id.match(newRe);
|
|
24
|
+
if (m) {
|
|
25
|
+
const n = parseInt(m[1], 10);
|
|
26
|
+
if (n > max) max = n;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return max;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeNextTicketSequence(nextTicketSequence, entries = []) {
|
|
33
|
+
const fallbackNum = maxTicketNumberFromEntries(entries);
|
|
34
|
+
const fallbackNext = fallbackNum < 9999 ? (fallbackNum + 1) : fallbackNum;
|
|
35
|
+
const parsed = parseNextTicketSequence(nextTicketSequence);
|
|
36
|
+
if (parsed === null) return fallbackNext || 1;
|
|
37
|
+
return Math.max(parsed, fallbackNext || 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeJsonFileAtomically(absPath, data) {
|
|
41
|
+
const serialized = JSON.stringify(data, null, 2) + "\n";
|
|
42
|
+
const tmpPath = `${absPath}.tmp-${process.pid}-${Date.now()}`;
|
|
43
|
+
writeFileLF(tmpPath, serialized);
|
|
44
|
+
try {
|
|
45
|
+
renameSync(tmpPath, absPath);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// POSIX rename(2)은 대상을 원자적으로 덮어쓰지만, Windows renameSync는 대상이
|
|
48
|
+
// 이미 있으면 EEXIST/EPERM으로 throw한다. 그 경우 대상을 먼저 지우고 재시도하고,
|
|
49
|
+
// 그래도 실패하면 직접 덮어쓰기로 폴백한다. 어느 경로든 tmp는 남기지 않는다.
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(absPath)) {
|
|
52
|
+
unlinkSync(absPath);
|
|
53
|
+
renameSync(tmpPath, absPath);
|
|
54
|
+
} else {
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
writeFileLF(absPath, serialized);
|
|
59
|
+
if (existsSync(tmpPath)) {
|
|
60
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listArchiveIndexFiles(dir) {
|
|
67
|
+
if (!existsSync(dir)) return [];
|
|
68
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
69
|
+
.filter(ent => ent.isFile())
|
|
70
|
+
.map(ent => makePath(dir, ent.name))
|
|
71
|
+
.filter(abs => ARCHIVE_INDEX_LEGACY_RE.test(basename(abs)) || ARCHIVE_INDEX_MONTH_RE.test(basename(abs)))
|
|
72
|
+
.sort((a, b) => {
|
|
73
|
+
const aBase = basename(a);
|
|
74
|
+
const bBase = basename(b);
|
|
75
|
+
const aMatch = aBase.match(ARCHIVE_INDEX_MONTH_RE);
|
|
76
|
+
const bMatch = bBase.match(ARCHIVE_INDEX_MONTH_RE);
|
|
77
|
+
if (aMatch && !bMatch) return 1;
|
|
78
|
+
if (!aMatch && bMatch) return -1;
|
|
79
|
+
if (!aMatch && !bMatch) return aBase.localeCompare(bBase);
|
|
80
|
+
return aMatch[1].localeCompare(bMatch[1]);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseIndexFile(absPath) {
|
|
85
|
+
if (!existsSync(absPath)) {
|
|
86
|
+
return { version: 1, updatedAt: null, nextTicketSequence: 1, entries: [] };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const j = JSON.parse(readFileSync(absPath, "utf8"));
|
|
90
|
+
const entries = Array.isArray(j.entries) ? j.entries.map(e => {
|
|
91
|
+
const entry = { ...e, status: e.status || "open", group: normalizeTicketGroup(e.group, "sub") };
|
|
92
|
+
entry.path = computeTicketPath(entry);
|
|
93
|
+
return entry;
|
|
94
|
+
}) : [];
|
|
95
|
+
// activeTicketId/activeTickets are no longer read from INDEX.json — focus is
|
|
96
|
+
// owned solely by the marker file. Any legacy fields on disk are ignored.
|
|
97
|
+
return {
|
|
98
|
+
version: j.version || 1,
|
|
99
|
+
updatedAt: j.updatedAt ?? null,
|
|
100
|
+
nextTicketSequence: parseNextTicketSequence(j.nextTicketSequence),
|
|
101
|
+
entries
|
|
102
|
+
};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`[ERROR] Failed to parse ${basename(absPath)} at ${absPath}:`, err.message);
|
|
105
|
+
return { version: 1, updatedAt: null, nextTicketSequence: 1, entries: [], _corrupt: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getWorkspaceKey(cwd) {
|
|
110
|
+
return resolveWorkspaceContext(cwd).breadcrumb;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getWorkspaceSlug(cwd) {
|
|
114
|
+
const key = getWorkspaceKey(cwd);
|
|
115
|
+
return key.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'default';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function splitEntriesForStorage(entries = []) {
|
|
119
|
+
const activeEntries = [];
|
|
120
|
+
const archiveEntries = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const status = String(entry?.status || "open").toLowerCase();
|
|
123
|
+
const phase = Number(entry?.phase || 1);
|
|
124
|
+
if ((status === "open" || status === "active") && phase < 4 && !entry?.archiveYearMonth) {
|
|
125
|
+
activeEntries.push(entry);
|
|
126
|
+
} else {
|
|
127
|
+
archiveEntries.push(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { activeEntries, archiveEntries };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
export function readTicketIndexJson(cwd) {
|
|
135
|
+
const dir = detectConsumerTicketDir(cwd);
|
|
136
|
+
if (!dir) return { version: 1, updatedAt: null, entries: [] };
|
|
137
|
+
const mainPath = makePath(dir, TICKET_INDEX_FILENAME);
|
|
138
|
+
const main = parseIndexFile(mainPath);
|
|
139
|
+
|
|
140
|
+
// The pointer file no longer stores entries — the ticket list is derived from
|
|
141
|
+
// the .md files (single source of truth). Scan them here so every consumer of
|
|
142
|
+
// readTicketIndexJson still sees a populated entry list.
|
|
143
|
+
const entries = collectTicketEntriesFromTopicFiles(cwd).map(entry => {
|
|
144
|
+
const next: Record<string, any> = { ...entry, status: entry.status || "open", group: normalizeTicketGroup(entry.group, "sub") };
|
|
145
|
+
next.path = computeTicketPath(next);
|
|
146
|
+
return next;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// #685: LangGraph 모델 — state(현재 노드)는 .md 프론트매터(phase/status)가 유일 SSOT.
|
|
150
|
+
// sessionId/쿠키/claim 폴백 레이어는 전면 제거됨. 현재 노드(activeTicketId)는 디스크의
|
|
151
|
+
// .md 스캔 결과에서 결정적으로 도출한다: live = (open/active) AND phase<4.
|
|
152
|
+
// 여러 개 live면 가장 진행된 노드(phase 높은 것, 동률이면 최신 createdAt)가 현재 노드.
|
|
153
|
+
// 값이 없으면 폴백이 아니라 "현재 노드 없음(null) = START 진입점"이다.
|
|
154
|
+
const liveEntries = entries.filter(e => {
|
|
155
|
+
const status = String(e.status || "open").toLowerCase();
|
|
156
|
+
return (status === "open" || status === "active") && Number(e.phase || 1) < 4 && !e.archiveYearMonth;
|
|
157
|
+
});
|
|
158
|
+
const sortedLive = [...liveEntries].sort((a, b) => {
|
|
159
|
+
const phaseDiff = Number(b.phase || 1) - Number(a.phase || 1);
|
|
160
|
+
if (phaseDiff !== 0) return phaseDiff;
|
|
161
|
+
return String(b.createdAt || "").localeCompare(String(a.createdAt || ""));
|
|
162
|
+
});
|
|
163
|
+
const activeTicketId = sortedLive.length > 0 ? sortedLive[0].id : null;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
version: main.version || 1,
|
|
167
|
+
updatedAt: main.updatedAt ?? null,
|
|
168
|
+
activeTicketId,
|
|
169
|
+
liveTicketIds: sortedLive.map(e => e.id),
|
|
170
|
+
nextTicketSequence: normalizeNextTicketSequence(main.nextTicketSequence, entries),
|
|
171
|
+
entries,
|
|
172
|
+
_corrupt: Boolean(main._corrupt)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function writeTicketIndexJson(cwd, indexJson, opts: CliOpts = {}) {
|
|
177
|
+
if (indexJson._corrupt && !opts.force) {
|
|
178
|
+
console.error(`[ABORT] Refusing to overwrite potentially corrupt ${TICKET_INDEX_FILENAME}. Use --force to override.`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const dir = detectConsumerTicketDir(cwd, { createIfMissing: true });
|
|
182
|
+
const p = makePath(dir, TICKET_INDEX_FILENAME);
|
|
183
|
+
if (opts.dryRun) return;
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
// The pointer file is a THIN focus-state file. The ticket list itself is
|
|
187
|
+
// derived from .md files (the single source of truth), so we never persist
|
|
188
|
+
// entries or per-month archive index files. We keep only the two facts that
|
|
189
|
+
// cannot be re-derived from .md alone: the focus pointer (activeTicketId) and
|
|
190
|
+
// the monotonically increasing next ticket number (nextTicketSequence).
|
|
191
|
+
const entries = Array.isArray(indexJson.entries) ? indexJson.entries : [];
|
|
192
|
+
|
|
193
|
+
const currentDiskState = parseIndexFile(p);
|
|
194
|
+
|
|
195
|
+
// nextTicketSequence must never go backwards: max of the stored pointer and
|
|
196
|
+
// (highest known ticket number across ALL entries, active or archived) + 1.
|
|
197
|
+
const callerNextSequence = normalizeNextTicketSequence(indexJson.nextTicketSequence, entries);
|
|
198
|
+
const diskNextSequence = normalizeNextTicketSequence(currentDiskState.nextTicketSequence, entries);
|
|
199
|
+
const nextTicketSequence = Math.max(callerNextSequence, diskNextSequence);
|
|
200
|
+
|
|
201
|
+
const out = {
|
|
202
|
+
version: indexJson.version || 1,
|
|
203
|
+
updatedAt: indexJson.updatedAt || new Date().toISOString(),
|
|
204
|
+
// activeTickets is deliberately omitted to prevent centralized contention
|
|
205
|
+
nextTicketSequence
|
|
206
|
+
};
|
|
207
|
+
writeJsonFileAtomically(p, out);
|
|
208
|
+
|
|
209
|
+
// Remove any legacy per-month archive index files — entries are no longer
|
|
210
|
+
// persisted, so these stale duplicates must not linger.
|
|
211
|
+
for (const filePath of listArchiveIndexFiles(dir)) {
|
|
212
|
+
unlinkSync(filePath);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// #675: Claims ticketId for the current session using cookie touch file.
|
|
217
|
+
// Pass ticketId=null to release.
|
|
218
|
+
export function setActiveTicketMarker(cwd, ticketId, opts: CliOpts = {}) {
|
|
219
|
+
// #685: LangGraph 모델 — 현재 노드는 .md phase가 SSOT다. 노드 위치를 쿠키/마커에
|
|
220
|
+
// 별도 저장하지 않는다(폴백 레이어 전면 제거). 이 함수는 하위 호환을 위한 no-op이며,
|
|
221
|
+
// 노드 이동은 ticket move가 .md phase를 갱신하는 것으로만 일어난다.
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// #685: getHostnameSlug 제거 — 티켓 ID에서 hostname suffix를 뺐으므로 불필요(언니 지시).
|
|
226
|
+
|
|
227
|
+
// #685: claim/actor/세션 마커 레이어 전면 제거 — LangGraph 모델에서 현재 노드는 .md
|
|
228
|
+
// phase가 SSOT이고 단일 사용자 환경이라 "누가 티켓을 잡았나" 추적이 불필요하다.
|
|
229
|
+
// resolveActor/readClaim/writeClaim/releaseClaim/isClaimStale/migrateLegacyActiveMarkers
|
|
230
|
+
// (전부 sessionId 쿠키 폴백 의존)와 CLAIM_STALE_MS를 삭제했다.
|
|
231
|
+
|
|
232
|
+
export function computeNextTicketNumber(indexState) {
|
|
233
|
+
const maybeEntries = Array.isArray(indexState) ? indexState : indexState?.entries;
|
|
234
|
+
const parsedNext = normalizeNextTicketSequence(indexState?.nextTicketSequence, maybeEntries);
|
|
235
|
+
return { num: parsedNext };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function generateTicketId(titleSlug, indexState) {
|
|
239
|
+
// #685: 티켓 ID의 hostname suffix(-joy-deep 등) 제거 — 단일 환경이라 호스트 구분이
|
|
240
|
+
// 무의미하다(언니 지시). ID는 {num}-{slug}로만 구성한다.
|
|
241
|
+
const slug = requireNonEmptySlug(titleSlug, "ticket title");
|
|
242
|
+
const match = slug.match(/^(\d{3,4})-(.*)/);
|
|
243
|
+
const idSlug = match ? requireNonEmptySlug(match[2], "ticket title") : slug;
|
|
244
|
+
if (/^\d{3,4}$/.test(idSlug)) {
|
|
245
|
+
throw new Error("[VALIDATION FAILED] ticket title must include a non-numeric slug; ticket numbers are assigned by INDEX nextTicketSequence.");
|
|
246
|
+
}
|
|
247
|
+
const { num } = computeNextTicketNumber(indexState);
|
|
248
|
+
const numStr = String(num).padStart(3, '0');
|
|
249
|
+
const finalSlug = idSlug.slice(0, 32);
|
|
250
|
+
return `${numStr}-${finalSlug}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function syncActiveTicketId(cwd, opts: CliOpts = {}) {
|
|
254
|
+
// #685: LangGraph 모델 — 현재 노드는 .md phase가 SSOT라 마커 동기화가 불필요하다.
|
|
255
|
+
// 이 함수는 이제 레거시 포인터 파일(LATEST.md/ACTIVE_TICKET.*) 정리만 담당한다.
|
|
256
|
+
const ticketDir = detectConsumerTicketDir(cwd);
|
|
257
|
+
if (!ticketDir) return;
|
|
258
|
+
if (opts.dryRun) return;
|
|
259
|
+
|
|
260
|
+
const legacyLatestPath = makePath(ticketDir, "LATEST.md");
|
|
261
|
+
const pointerPathMd = makePath(ticketDir, "ACTIVE_TICKET.md");
|
|
262
|
+
const pointerPathJson = makePath(ticketDir, "ACTIVE_TICKET.json");
|
|
263
|
+
|
|
264
|
+
for (const p of [legacyLatestPath, pointerPathMd, pointerPathJson]) {
|
|
265
|
+
if (existsSync(p)) {
|
|
266
|
+
unlinkSync(p);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function syncToPipeline(url, data) {
|
|
272
|
+
try {
|
|
273
|
+
const response = await fetch(url, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
body: JSON.stringify(data),
|
|
277
|
+
signal: AbortSignal?.timeout ? AbortSignal.timeout(3000) : undefined
|
|
278
|
+
});
|
|
279
|
+
return response.ok;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync,
|
|
2
|
-
import { basename, dirname
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, renameSync } from "fs";
|
|
2
|
+
import { basename, dirname } from "path";
|
|
3
3
|
import {
|
|
4
|
-
toRepoRelativePath,
|
|
5
|
-
parseFrontMatter,
|
|
6
|
-
} from "./cli-utils.
|
|
7
|
-
import { readTicketIndexJson,
|
|
8
|
-
import { collectTicketMarkdownFiles, rebuildTicketIndexFromTopicFilesIfNeeded } from "./cli-ticket-parser.
|
|
4
|
+
toRepoRelativePath, TICKET_SUBDIR, TICKET_LIST_FILENAME,
|
|
5
|
+
parseFrontMatter, findFileRecursively, detectConsumerTicketDir, makePath
|
|
6
|
+
, CliOpts } from "./cli-utils.js";
|
|
7
|
+
import { readTicketIndexJson, syncActiveTicketId, writeTicketIndexJson } from "./cli-ticket-index.js";
|
|
8
|
+
import { collectTicketMarkdownFiles, rebuildTicketIndexFromTopicFilesIfNeeded } from "./cli-ticket-parser.js";
|
|
9
|
+
import { splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
|
|
10
|
+
import { writeTicketDocument } from "./cli-ticket-document.js";
|
|
9
11
|
|
|
10
12
|
// ─── Summary Extraction ─────────────────────────────────────────────────────
|
|
11
13
|
|
|
@@ -22,7 +24,7 @@ export function extractSummary(meta, content) {
|
|
|
22
24
|
const lines = content.split("\n");
|
|
23
25
|
|
|
24
26
|
// Collect section contents by known header patterns
|
|
25
|
-
const sections = {};
|
|
27
|
+
const sections: Record<string, any> = {};
|
|
26
28
|
let currentSection = null;
|
|
27
29
|
for (const line of lines) {
|
|
28
30
|
const trimmed = line.trim();
|
|
@@ -147,7 +149,7 @@ export function ensureCautionBlock(content) {
|
|
|
147
149
|
|
|
148
150
|
// ─── Main Migration ─────────────────────────────────────────────────────────
|
|
149
151
|
|
|
150
|
-
export function performUpgradeMigration(cwd, opts = {}) {
|
|
152
|
+
export function performUpgradeMigration(cwd, opts: CliOpts = {}) {
|
|
151
153
|
const root = detectConsumerTicketDir(cwd, { createIfMissing: true });
|
|
152
154
|
|
|
153
155
|
const files = collectTicketMarkdownFiles(root).filter(p => {
|
|
@@ -191,8 +193,8 @@ export function performUpgradeMigration(cwd, opts = {}) {
|
|
|
191
193
|
// 3. Archive placement fix (existing logic)
|
|
192
194
|
if (meta.status === "archived" && !isAlreadyInArchive && !opts.dryRun) {
|
|
193
195
|
const finalAbs = moveFileToArchive(cwd, abs, meta.group || basename(dirname(abs)));
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
+
const ticketDocMeta = splitTicketDocMetaSection(modifiedContent);
|
|
197
|
+
writeTicketDocument(finalAbs, { meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
|
|
196
198
|
upgraded++;
|
|
197
199
|
console.log(`[OK] Upgraded + archived: ${toRepoRelativePath(cwd, finalAbs)}`);
|
|
198
200
|
continue;
|
|
@@ -205,8 +207,8 @@ export function performUpgradeMigration(cwd, opts = {}) {
|
|
|
205
207
|
if (cautionResult !== null) changes.push("caution+targetModule");
|
|
206
208
|
console.log(`[DRY-RUN] Would upgrade: ${rel} (+${changes.join(", ")})`);
|
|
207
209
|
} else {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
+
const ticketDocMeta = splitTicketDocMetaSection(modifiedContent);
|
|
211
|
+
writeTicketDocument(abs, { meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
|
|
210
212
|
upgraded++;
|
|
211
213
|
}
|
|
212
214
|
}
|
|
@@ -216,67 +218,26 @@ export function performUpgradeMigration(cwd, opts = {}) {
|
|
|
216
218
|
|
|
217
219
|
if (!opts.dryRun) {
|
|
218
220
|
rebuildTicketIndexFromTopicFilesIfNeeded(cwd, { ...opts, force: true });
|
|
219
|
-
performDefragmentation(cwd, opts);
|
|
220
221
|
syncActiveTicketId(cwd);
|
|
221
222
|
}
|
|
222
|
-
|
|
223
|
+
|
|
223
224
|
return upgraded;
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (!rootTicketDir) return;
|
|
231
|
-
const tickets = collectTicketMarkdownFiles(rootTicketDir).filter(p => {
|
|
232
|
-
const base = basename(p);
|
|
233
|
-
return base !== "LATEST.md" && base !== TICKET_LIST_FILENAME && base !== "ACTIVE_TICKET.md";
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
console.log(`[DEFRAG] Checking ${tickets.length} tickets for workspace placement...`);
|
|
237
|
-
|
|
238
|
-
const modifiedPaths = new Set();
|
|
239
|
-
|
|
240
|
-
for (const abs of tickets) {
|
|
241
|
-
const { meta } = parseFrontMatter(readFileSync(abs, "utf8"));
|
|
242
|
-
if (meta.submodule && meta.submodule !== "global") {
|
|
243
|
-
const subPath = join(cwd, meta.submodule);
|
|
244
|
-
if (existsSync(subPath) && statSync(subPath).isDirectory()) {
|
|
245
|
-
const subTicketDir = join(subPath, AGENT_ROOT_DIR, TICKET_SUBDIR);
|
|
246
|
-
|
|
247
|
-
const relToRoot = relative(rootTicketDir, abs);
|
|
248
|
-
const destAbs = join(subTicketDir, relToRoot);
|
|
249
|
-
|
|
250
|
-
if (opts.dryRun) {
|
|
251
|
-
console.log(`[DRY-RUN] Would move to workspace: ${relToRoot} -> ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/`);
|
|
252
|
-
} else {
|
|
253
|
-
mkdirSync(dirname(destAbs), { recursive: true });
|
|
254
|
-
copyFileSync(abs, destAbs);
|
|
255
|
-
unlinkSync(abs);
|
|
256
|
-
console.log(`[DEFRAG] Moved: ${meta.submodule}/${AGENT_ROOT_DIR}/${TICKET_SUBDIR}/${relToRoot}`);
|
|
257
|
-
modifiedPaths.add(subPath);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (!opts.dryRun) {
|
|
264
|
-
for (const p of modifiedPaths) {
|
|
265
|
-
rebuildTicketIndexFromTopicFilesIfNeeded(p, { ...opts, force: true });
|
|
266
|
-
syncActiveTicketId(p);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
227
|
+
// #727: performDefragmentation 제거 — 홈 uuid 스토어의 티켓을 meta.submodule 기준으로
|
|
228
|
+
// 서브모듈 .deuk/tickets 로 역이동(cwd write)시켜 홈 일원화(#622)를 되돌리던 코드였다.
|
|
229
|
+
// normalizeTicketPaths 주석('티켓은 홈에 산다, cwd로 끌어내면 안 됨')과 정면 모순이라
|
|
230
|
+
// 함수와 호출(upgrade 흐름)을 통째로 제거. 티켓은 홈에만 산다.
|
|
270
231
|
|
|
271
232
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
272
233
|
|
|
273
234
|
export function moveFileToArchive(cwd, abs, group) {
|
|
274
235
|
const ticketDir = detectConsumerTicketDir(cwd);
|
|
275
|
-
const archiveBase =
|
|
236
|
+
const archiveBase = makePath(ticketDir, "archive");
|
|
276
237
|
const targetSubDir = (basename(ticketDir) === TICKET_SUBDIR || !group) ? "sub" : group;
|
|
277
|
-
const targetDir =
|
|
238
|
+
const targetDir = makePath(archiveBase, targetSubDir);
|
|
278
239
|
mkdirSync(targetDir, { recursive: true });
|
|
279
|
-
const finalAbs =
|
|
240
|
+
const finalAbs = makePath(targetDir, basename(abs));
|
|
280
241
|
if (finalAbs !== abs) {
|
|
281
242
|
if (existsSync(finalAbs)) {
|
|
282
243
|
unlinkSync(abs);
|
|
@@ -288,7 +249,7 @@ export function moveFileToArchive(cwd, abs, group) {
|
|
|
288
249
|
return finalAbs;
|
|
289
250
|
}
|
|
290
251
|
|
|
291
|
-
export function normalizeTicketPaths(cwd, opts = {}) {
|
|
252
|
+
export function normalizeTicketPaths(cwd, opts: CliOpts = {}) {
|
|
292
253
|
const index = readTicketIndexJson(cwd);
|
|
293
254
|
const ticketDir = detectConsumerTicketDir(cwd);
|
|
294
255
|
const entries = index.entries || [];
|
|
@@ -296,15 +257,30 @@ export function normalizeTicketPaths(cwd, opts = {}) {
|
|
|
296
257
|
|
|
297
258
|
for (const entry of entries) {
|
|
298
259
|
if (!entry.path) continue;
|
|
299
|
-
|
|
300
|
-
|
|
260
|
+
|
|
261
|
+
// #622/#623: 티켓 .md는 cwd(워크스페이스 git 트리)가 아니라 홈 ticketDir에 산다.
|
|
262
|
+
// 정상 위치는 makePath(ticketDir, entry.path)이다. entry.path를 cwd 기준으로 해석해
|
|
263
|
+
// 그쪽으로 옮기면, 홈에 멀쩡히 있던 .md를 워크스페이스로 끌어내 홈 디렉토리를 비우고
|
|
264
|
+
// (그 순간 rebuild 스캔이 entries=0을 보고 active claim을 고아로 오인해 삭제한다).
|
|
265
|
+
// 따라서 파일이 이미 ticketDir 하위에 있으면 그게 올바른 위치이므로 이동하지 않는다.
|
|
266
|
+
const canonicalAbs = makePath(ticketDir, entry.path);
|
|
267
|
+
if (existsSync(canonicalAbs)) continue;
|
|
268
|
+
|
|
269
|
+
const currentAbs = makePath(cwd, entry.path);
|
|
301
270
|
if (!existsSync(currentAbs)) {
|
|
302
271
|
const fileName = basename(entry.path);
|
|
303
272
|
const found = findFileRecursively(ticketDir, fileName);
|
|
304
273
|
if (found) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
274
|
+
// 발견된 파일이 이미 홈 ticketDir 하위면 정상 위치 — 워크스페이스로 옮기지 않는다.
|
|
275
|
+
const destAbs = canonicalAbs;
|
|
276
|
+
if (found !== destAbs) {
|
|
277
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
renameSync(found, destAbs);
|
|
280
|
+
} catch {
|
|
281
|
+
copyFileSync(found, destAbs);
|
|
282
|
+
unlinkSync(found);
|
|
283
|
+
}
|
|
308
284
|
modified = true;
|
|
309
285
|
}
|
|
310
286
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import {
|
|
3
|
+
parseFrontMatter, discoverAllWorkspaces, detectConsumerTicketDir, computeTicketPath, makePath
|
|
4
|
+
, CliOpts } from "./cli-utils.js";
|
|
5
|
+
import { readTicketIndexJson, writeTicketIndexJson } from "./cli-ticket-index.js";
|
|
6
|
+
import { splitTicketDocMetaSection } from "./cli-ticket-docmeta.js";
|
|
7
|
+
import { writeTicketDocument } from "./cli-ticket-document.js";
|
|
8
|
+
|
|
9
|
+
export { collectTicketMarkdownFiles } from "./cli-ticket-scan.js";
|
|
10
|
+
|
|
11
|
+
export function discoverAllTicketDirs(baseCwd, out = []) {
|
|
12
|
+
return discoverAllWorkspaces(baseCwd, undefined, new Set(out));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts: CliOpts = {}) {
|
|
16
|
+
// readTicketIndexJson already derives entries from the .md files (single source
|
|
17
|
+
// of truth) and merges in the thin pointer state (activeTicketId/nextTicketSequence).
|
|
18
|
+
const indexJson = readTicketIndexJson(cwd);
|
|
19
|
+
|
|
20
|
+
const ticketDir = detectConsumerTicketDir(cwd);
|
|
21
|
+
if (!ticketDir) return indexJson;
|
|
22
|
+
|
|
23
|
+
// Persist the thin pointer file so activeTicketId/nextTicketSequence are kept in
|
|
24
|
+
// sync on disk. writeTicketIndexJson stores ONLY the pointer state, never entries.
|
|
25
|
+
writeTicketIndexJson(cwd, indexJson, indexJson._corrupt ? { ...opts, force: true } : opts);
|
|
26
|
+
if (opts.rebuild) console.log(`[REBUILD] Ticket pointer synced; ${indexJson.entries.length} entries derived from .md.`);
|
|
27
|
+
return indexJson;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function appendTicketEntry(cwd, entry, opts: CliOpts = {}) {
|
|
31
|
+
const indexJson = readTicketIndexJson(cwd);
|
|
32
|
+
entry.status = entry.status || "open";
|
|
33
|
+
// We no longer store 'path' snapshots in INDEX.json
|
|
34
|
+
const { path, ...cleanEntry } = entry;
|
|
35
|
+
const next = {
|
|
36
|
+
version: indexJson.version || 1,
|
|
37
|
+
updatedAt: new Date().toISOString(),
|
|
38
|
+
activeTicketId: indexJson.activeTicketId,
|
|
39
|
+
entries: [cleanEntry, ...indexJson.entries]
|
|
40
|
+
};
|
|
41
|
+
writeTicketIndexJson(cwd, next, opts);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updateTicketEntryStatus(cwd, opts: CliOpts = {}) {
|
|
45
|
+
const indexJson = rebuildTicketIndexFromTopicFilesIfNeeded(cwd, opts);
|
|
46
|
+
let foundIndex = -1;
|
|
47
|
+
const targetId = opts.ticketId ? String(opts.ticketId).toLowerCase() : null;
|
|
48
|
+
|
|
49
|
+
if (opts.latest) {
|
|
50
|
+
foundIndex = 0;
|
|
51
|
+
} else if (targetId) {
|
|
52
|
+
foundIndex = indexJson.entries.findIndex(e =>
|
|
53
|
+
String(e.id || "").toLowerCase().includes(targetId)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (foundIndex === -1) {
|
|
58
|
+
throw new Error("No matching ticket found to update status");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entry = indexJson.entries[foundIndex];
|
|
62
|
+
const newStatus = opts.status || "closed";
|
|
63
|
+
entry.status = newStatus;
|
|
64
|
+
if (newStatus === "open" || newStatus === "active") {
|
|
65
|
+
delete entry.archiveYearMonth;
|
|
66
|
+
delete entry.archiveDay;
|
|
67
|
+
}
|
|
68
|
+
if (newStatus === "closed" || newStatus === "completed" || newStatus === "cancelled" || newStatus === "wontfix") {
|
|
69
|
+
entry.phase = 4;
|
|
70
|
+
}
|
|
71
|
+
entry.updatedAt = new Date().toISOString();
|
|
72
|
+
|
|
73
|
+
// Sync status back to .md frontmatter to prevent rebuild reversion.
|
|
74
|
+
// #736: cwd is the WORKSPACE root; tickets live in the HOME store (detectConsumerTicketDir).
|
|
75
|
+
// makePath(workspaceRoot, relativePath) misses the file → use the ticket dir as base.
|
|
76
|
+
const entryPath = entry.path || computeTicketPath(entry);
|
|
77
|
+
const ticketDir = detectConsumerTicketDir(cwd) || cwd;
|
|
78
|
+
const absPath = makePath(ticketDir, entryPath);
|
|
79
|
+
if (existsSync(absPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const body = readFileSync(absPath, "utf8");
|
|
82
|
+
const parsed = parseFrontMatter(body);
|
|
83
|
+
if (parsed.parseError) throw new Error(parsed.parseError);
|
|
84
|
+
if (parsed.meta.status !== newStatus) {
|
|
85
|
+
parsed.meta.status = newStatus;
|
|
86
|
+
if (newStatus === "closed" || newStatus === "completed" || newStatus === "cancelled" || newStatus === "wontfix") {
|
|
87
|
+
parsed.meta.phase = 4;
|
|
88
|
+
}
|
|
89
|
+
const ticketDocMeta = splitTicketDocMetaSection(parsed.content || "");
|
|
90
|
+
writeTicketDocument(absPath, { meta: parsed.meta, content: ticketDocMeta.body, docmeta: ticketDocMeta.docmeta });
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(`[WARNING] Failed to sync status to ${entryPath}: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const next = { version: indexJson.version, updatedAt: new Date().toISOString(), entries: indexJson.entries };
|
|
98
|
+
writeTicketIndexJson(cwd, next, opts);
|
|
99
|
+
return entry;
|
|
100
|
+
}
|