@angar2/taskery 0.8.0 → 0.8.1

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.
@@ -0,0 +1,60 @@
1
+ // 과거 taskery 진입 문서 템플릿이 쓰던 인용문 목록 — 재조립 시 리포 소유 문장과 구별하는 기준
2
+ /**
3
+ * bin/legacy-quotes.js
4
+ * v0.1.0~v0.8.0의 `template/CLAUDE.md`·`template/AGENTS.md`에 실렸던 `>` 인용문 전량(유니크 42줄).
5
+ *
6
+ * 쓰임: AGENTS.md 재조립(migrate.composeAgentsDoc)에서 리포 문서의 인용문을 판정한다.
7
+ * - 이 목록 또는 *현재 템플릿*에 있는 문장 → taskery가 쓴 것 → 버리고 새 문안으로 교체
8
+ * - 어느 쪽에도 없는 문장 → 리포가 쓴 것 → 그대로 보존
9
+ * 판정 실패는 **보존 쪽으로 실패한다** — 목록에 없으면 남긴다(중복은 눈에 띄지만 유실은 복구 불가).
10
+ *
11
+ * 생성 방법(재현): 각 릴리스 태그에서 두 템플릿의 `^> ` 줄을 뽑아 정렬·유니크.
12
+ * 새 버전의 문안은 목록에 추가할 필요가 없다 — 재조립이 현재 템플릿에서 실시간으로 읽는다.
13
+ */
14
+
15
+ const LEGACY_TEMPLATE_QUOTES = [
16
+ '> **검증 방법 → `.project/TEST-GUIDE.md`** — 각 테스트 방식(데이터 조회 / API 호출 / E2E / 시각 실행)을 *이 프로젝트에서 실제로 어떻게 돌리나*는 거기 단일 소스. `/task-plan`이 채우고 `/task-test` 격리 세션이 읽는다.',
17
+ '> **본 문서에는 규칙·사용법을 추가하지 않는다** — 규칙은 `.project/rules/`, 이 리포의 커스텀은 `*.local.md`가 담당한다. 본 문서에서 갱신이 허용되는 것은 아래 리포 값 칸(프로젝트 메타 · 각 명령)뿐이다.',
18
+ '> *코드 상태 검증* (빌드 / 린트 / 타입체크). 테스트 실행은 본 섹션 X — `## 테스트 명령` 참조.',
19
+ '> *테스트 실행* (단위 / 통합 / E2E 등). 코드 상태 검증은 `## 검증 명령` 참조.',
20
+ '> - Hook: `.codex/config.toml`의 `[[hooks.PreToolUse]]`로 등록 — 최초 1회 `/hooks` trust 승인 필요',
21
+ '> - 격리 검증(/task-test): `.codex/agents/task-tester.toml` 서브에이전트로 spawn',
22
+ '> - 스킬 본문: `.agents/skills/<스킬>/SKILL.md` (Claude의 `.claude/skills/`가 아님)',
23
+ '> CLI/라이브러리 등 검수 서버가 없는 프로젝트는 본 섹션 전체 삭제.',
24
+ '> Codex 환경 차이 (Claude와 다른 점만):',
25
+ '> `.codex/config.toml`의 `[[hooks.PreToolUse]]`로 등록. 최초 1회 `/hooks` trust 승인 후 자동 작동.',
26
+ '> `.project/tasks/<NNN_slug>/BACKLOG.md` = *plan(기능 그룹)별 task 후보 누적*. 사용자 발화로 1건씩 얕은 분석(개요 / 대상 영역) 곁들여 추가.',
27
+ '> `.project/tasks/<vX.X>/BACKLOG.md` = *버전별 task 후보 누적*. 사용자 발화로 1건씩 얕은 분석(개요 / 대상 영역) 곁들여 추가.',
28
+ '> `/project-init` 호출 시 위 4 항목 채워짐. 변경 필요하면 직접 Edit.',
29
+ '> `/project-init` 호출 시 위 4 항목 채워짐. 변경 필요하면 직접 편집.',
30
+ '> `/run-team`은 Claude 전용 — agent teams(실험 기능)가 Codex에 없어 Codex 설치에는 미포함.',
31
+ '> `/task-dev` self-check / `/task-close` 최종 게이트가 *이 섹션을 단일 진실 소스로 참조*.',
32
+ '> `/task-dev` self-check / `/task-test` 격리 게이트 / `/task-close` 최종 게이트 / `pre-commit-verify.sh` hook이 *모두 이 섹션을 단일 진실 소스로 참조*.',
33
+ '> `/task-dev` 구현 후 테스트 + `/task-test` 격리 세션이 *이 섹션을 단일 진실 소스로 참조*.',
34
+ '> `/task-dev`·`/task-test`가 검수 시점(세션 중단점)에 이 명령으로 서버를 *백그라운드* 기동하고 접속 URL을 보고한다.',
35
+ '> closed-immutable.sh는 코덱스 파일편집 도구(apply_patch)의 patch envelope에서 `*** Update File:` 경로를 파싱해 검사한다(Claude판은 `file_path` 키를 읽음 — 메커니즘만 다르고 효과 동일).',
36
+ '> taskery는 *plan/ ↔ template/ 자동 빌드 X*. 사용자가 직접 정합 유지.',
37
+ '> 각 명령은 그대로 실행 가능한 형태로 적는다. 해당 없는 항목은 행을 삭제한다.',
38
+ '> 다건 태스크를 *리더 메인 세션 1개가 agent teams로 팀원(독립 세션)에 분배*해 자동 병렬 처리하는 고기능. 위 멀티세션을 사용자가 일일이 띄우는 대신 리더가 띄우고 관리한다. 워크트리 격리·머지 직렬화는 기존 `/task-init`·`/task-close`가 그대로 담당.',
39
+ '> 다건 태스크를 리더가 팀원(독립 세션)에 분배해 자동 병렬 처리하는 고기능(`/run-team`)은 **Claude 전용**이다 — 기반인 agent teams 기능이 Codex에 없다.',
40
+ '> 멀티세션 = 같은 프로젝트에서 *여러 메인 세션이 독립 태스크를 병렬*로 진행. git worktree로 작업 폴더 격리, dev 머지 시 직렬화.',
41
+ '> 멀티세션 = 같은 프로젝트에서 *여러 메인 세션이 독립 태스크를 병렬*로 진행. git worktree로 작업 폴더 격리, 부모 브랜치 머지 시 직렬화.',
42
+ '> 멀티세션 포트 격리: 메인 세션(부모 브랜치) = 기준 포트, task 워크트리 = 기준 포트 + TASK번호. 상세는 GIT_RULE "멀티세션 검수 환경" 섹션.',
43
+ '> 멀티세션 포트 격리: 메인/dev 세션 = 기준 포트, task 워크트리 = 기준 포트 + TASK번호. 상세는 GIT_RULE "멀티세션 검수 환경" 섹션.',
44
+ '> 백틱(`...`) 안 명령 그대로 실행됨. 언어/프레임워크 따라 변경.',
45
+ '> 본 룰은 *모든 스킬 호출 / 사용자 발화 처리 / 작업 진행*에 적용. 위반 시 사용자 마찰 직결.',
46
+ '> 본 문서는 에이전트 최상위 지침이다. 세션 시작 시 자동 로드된다.',
47
+ '> 사람용 도메인 설명은 `.project/PROJECT.md`, 진입 가이드는 `.project/AGENT-GUIDE.md` 참조.',
48
+ '> 앱을 띄워 사용자가 직접 확인해야 하는 프로젝트에만 해당한다. 아니면 이 절 전체를 삭제한다.',
49
+ '> 위 4 항목 중 *프로젝트에 해당 없는 것*은 행 자체 삭제. 빈 백틱(\\`\\`)은 사용 금지.',
50
+ '> 위 항목 중 *프로젝트에 해당 없는 것*은 행 자체 삭제. 빈 백틱(\\`\\`)은 사용 금지.',
51
+ '> 위 항목 중 *프로젝트에 해당 없는 것*은 행 자체 삭제. 빈 백틱(``)은 사용 금지.',
52
+ '> 이 파일은 Codex 세션이 *매 세션 시작 시 자동 로드*하는 단일 진입점이다.',
53
+ '> 이 파일은 메인 세션이 *매 세션 시작 시 자동 로드*하는 단일 진입점이다.',
54
+ '> 이전 `pre-commit-verify.sh` hook은 폐기 — task-close Step 2 게이트 + git-guard 안전망으로 충분 (redundant 검증 사이클 제거). 폐기 사유는 [plan/HOOKS.md](../plan/HOOKS.md) §5 참조.',
55
+ '> 프로젝트 메타 + 검증 명령 + 룰 참조 + 스킬 8종 + 동기화 룰만 포함한다.',
56
+ '> 프로젝트 메타 + 검증 명령 + 룰 참조 + 스킬 9종 + 동기화 룰만 포함한다.',
57
+ '> 프로젝트 메타 + 검증/테스트/검수 명령 + 룰 참조 + 스킬 9종 + 동기화 룰만 포함한다.',
58
+ ];
59
+
60
+ module.exports = { LEGACY_TEMPLATE_QUOTES };
package/bin/lib.js CHANGED
@@ -139,6 +139,12 @@ function sha256(filePath) {
139
139
  return 'sha256:' + crypto.createHash('sha256').update(buf).digest('hex');
140
140
  }
141
141
 
142
+ // 파일이 아니라 *써 넣을 내용*의 해시. 조립해서 설치하는 자산(AGENTS.md)은 템플릿 원본과
143
+ // 내용이 다르므로, manifest에는 반드시 조립 결과물의 해시를 기록해야 한다.
144
+ function sha256Text(text) {
145
+ return 'sha256:' + crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex');
146
+ }
147
+
142
148
  function walkTemplate(templateDir) {
143
149
  // template/ 안 모든 파일 상대경로 + 해시 맵 리턴
144
150
  // 결과: { 'CLAUDE.md': 'sha256:...', '.claude/skills/task-init/SKILL.md': 'sha256:...', ... }
@@ -1418,6 +1424,7 @@ module.exports = {
1418
1424
  gitignorePatternsFor,
1419
1425
  findTemplateDir,
1420
1426
  sha256,
1427
+ sha256Text,
1421
1428
  walkTemplate,
1422
1429
  mkdirp,
1423
1430
  copyFile,
package/bin/migrate.js CHANGED
@@ -5,11 +5,14 @@
5
5
  *
6
6
  * 이행 4종:
7
7
  * 1. ingestActivePlan — 구형 AGENT-GUIDE.md '## 활성 plan 버전' → manifest.activePlan (1회)
8
- * 2. migrateEntryDocs — 진입 문서 재편. 기존 CLAUDE.md/AGENTS.md의 *리포 값* 4섹션을 추출해
9
- * 새 AGENTS.md에 주입하고, CLAUDE.md는 `@AGENTS.md` 한 줄로 축약.
8
+ * 2. recomposeEntryDocs — 진입 문서 **재조립**. 현 진입 문서(AGENTS.md, 없으면 구 CLAUDE.md)에서
9
+ * 리포 내용을 건져 새 AGENTS.md 템플릿에 끼워넣는다. 최초 이행 때는
10
+ * CLAUDE.md를 `@AGENTS.md` 한 줄로 축약한다.
11
+ * **매 update 항상 실행한다(1회성 아님).** AGENTS.md는 리포 값을 품어
12
+ * 템플릿과 바이트 일치가 원리적으로 불가능하므로, 본 루프의 복사 대상이
13
+ * 되어선 안 된다(0.7.0 결함: 이행 후 본 루프가 값을 통째로 덮거나,
14
+ * 거절하면 구판 문안에 영구 고착됐다).
10
15
  * **update 본 루프보다 먼저 실행하고, 처리한 경로를 본 루프에서 제외한다.**
11
- * 본 루프에 맡기면 '사용자 customize 검출' 프롬프트가 뜨는데,
12
- * 덮으면 리포 값이 .bak에만 남고 안 덮으면 구판 전문이 남아 새 판과 충돌한다.
13
16
  * 3. migrateGitignore — AGENTS.md가 agnostic으로 바뀌어 모든 리포에 새로 깔리는데
14
17
  * .gitignore 기록은 init에만 있어 누락된다. 누락 패턴 append 제안.
15
18
  * 4. migrateTestGuide — 폐지되는 TEST-GUIDE.md의 축적 내용을 TEST_RULE.local.md로 1회 이관.
@@ -20,7 +23,8 @@
20
23
  const fs = require('fs');
21
24
  const path = require('path');
22
25
 
23
- const { sha256, copyFile, gitignorePatternsFor } = require('./lib');
26
+ const { sha256, sha256Text, copyFile, gitignorePatternsFor } = require('./lib');
27
+ const { LEGACY_TEMPLATE_QUOTES } = require('./legacy-quotes');
24
28
 
25
29
  // 진입 문서에서 이식할 *리포 값* 섹션. 헤딩 텍스트 접두로 매칭한다 —
26
30
  // 실제 문서의 헤딩은 '## 검수 실행 명령 (해당 시 — 앱 실행·검수가 필요한 프로젝트)'처럼
@@ -59,52 +63,144 @@ function splitSections(content) {
59
63
  return out;
60
64
  }
61
65
 
62
- function findSectionBody(sections, key) {
63
- const hit = sections.find((s) => s.title.startsWith(key));
64
- if (!hit) return null;
65
- // 뒤쪽 빈 줄과 구분선(---)은 새 템플릿 쪽 서식을 쓰므로 떼어낸다.
66
- const body = hit.body.slice();
67
- while (body.length && (body[body.length - 1].trim() === '' || body[body.length - 1].trim() === '---')) {
68
- body.pop();
66
+ // 문서를 { 머리말, 섹션[] }로 쪼갠다. splitSections와 달리 첫 '## ' 앞의 머리말을 버리지 않는다 —
67
+ // 재조립은 문서 전체를 다시 써내므로 머리말(제목·최상위 인용문)도 결과물에 들어가야 한다.
68
+ function parseDoc(content) {
69
+ const preamble = [];
70
+ const sections = [];
71
+ let cur = null;
72
+ let inFence = false;
73
+ for (const line of content.split('\n')) {
74
+ if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
75
+ const m = inFence ? null : line.match(/^##\s+(.*)$/);
76
+ if (m) {
77
+ cur = { heading: line, title: m[1].trim(), body: [] };
78
+ sections.push(cur);
79
+ } else if (cur) {
80
+ cur.body.push(line);
81
+ } else {
82
+ preamble.push(line);
83
+ }
69
84
  }
70
- while (body.length && body[0].trim() === '') body.shift();
71
- return body.join('\n');
85
+ return { preamble, sections };
72
86
  }
73
87
 
74
- // 새 AGENTS.md 템플릿의 해당 섹션 본문을 추출한 값으로 갈아끼운다.
75
- function injectSections(templateContent, extracted) {
76
- const lines = templateContent.split('\n');
77
- const heads = new Set();
78
- let fence = false;
79
- lines.forEach((l, idx) => {
80
- if (/^\s*(```|~~~)/.test(l)) fence = !fence;
81
- else if (!fence && /^##\s+/.test(l)) heads.add(idx);
82
- });
83
- const out = [];
88
+ // taskery가 쓴 인용문 집합 = 과거 템플릿 전량(고정 목록) ∪ *현재* 템플릿(실시간 추출).
89
+ // 현재 템플릿을 함께 넣는 것이 핵심이다 — 그래야 다음 버전의 새 문안도 그 다음 update에서
90
+ // 자기 자신을 중복 없이 걷어낸다(목록을 손으로 관리하지 않아도 영구히 자기유지된다).
91
+ function boilerplateQuotes(templateContent) {
92
+ const set = new Set(LEGACY_TEMPLATE_QUOTES.map((s) => s.trim()));
93
+ for (const l of templateContent.split('\n')) {
94
+ if (/^\s*>/.test(l)) set.add(l.trim());
95
+ }
96
+ return set;
97
+ }
98
+
99
+ // 우리가 수행한 파일 이름 변경 — 리포가 직접 쓴 문장 안의 죽은 경로를 대응 경로로 바꾼다.
100
+ // 1:1로 확정된 것만 넣는다(추측 치환 금지).
101
+ const DEAD_PATH_REWRITES = [['.project/TEST-GUIDE.md', '.project/rules/TEST_RULE.local.md']];
102
+
103
+ // 인용문 블록(헤딩 직후 연속된 '>' 줄)과 꼬리(끝의 빈 줄·구분선)를 템플릿 본문에서 뽑는다.
104
+ function splitTemplateBody(body) {
84
105
  let i = 0;
85
- while (i < lines.length) {
86
- const m = heads.has(i) ? lines[i].match(/^##\s+(.*)$/) : null;
87
- const key = m && REPO_VALUE_SECTIONS.find((k) => m[1].trim().startsWith(k));
88
- if (!key || extracted[key] == null) {
89
- out.push(lines[i]);
90
- i++;
106
+ while (i < body.length && body[i].trim() === '') i++;
107
+ const quotes = [];
108
+ while (i < body.length && /^\s*>/.test(body[i])) quotes.push(body[i++]);
109
+ const tail = [];
110
+ let j = body.length - 1;
111
+ while (j >= i && (body[j].trim() === '' || body[j].trim() === '---')) tail.unshift(body[j--]);
112
+ return { quotes, tail };
113
+ }
114
+
115
+ /**
116
+ * AGENTS.md 재조립 — 새 템플릿의 지침 + 현 문서의 리포 내용.
117
+ *
118
+ * 절마다: 새 템플릿 헤딩 + 새 템플릿 인용문 + 현 문서 본문(단, taskery 인용문은 제거).
119
+ * 값 줄·표·산문은 판정 대상이 아니라 전부 보존된다. 리포가 직접 쓴 인용문도 보존된다.
120
+ *
121
+ * @returns {{ content, kept, dropped, rewritten, omitted, missing }}
122
+ * kept — 보존한 리포 인용문 / dropped — 걷어낸 taskery 구 문안
123
+ * rewritten — 죽은 경로를 치환한 줄 / omitted — 현 문서에 없어 결과물에서 뺀 절
124
+ * missing — omitted 중 필수 절(호출자가 보류 판정에 쓴다)
125
+ */
126
+ function composeAgentsDoc(currentContent, templateContent) {
127
+ const tpl = parseDoc(templateContent);
128
+ const cur = parseDoc(currentContent);
129
+ const boiler = boilerplateQuotes(templateContent);
130
+ const kept = [];
131
+ const dropped = [];
132
+ const rewritten = [];
133
+ const omitted = [];
134
+ const out = [...tpl.preamble];
135
+
136
+ for (const sec of tpl.sections) {
137
+ const key = REPO_VALUE_SECTIONS.find((k) => sec.title.startsWith(k));
138
+ if (!key) {
139
+ out.push(sec.heading, ...sec.body);
91
140
  continue;
92
141
  }
93
- out.push(lines[i]); // 헤딩은 새 템플릿 것을 유지
94
- i++;
95
- // 기존 본문(다음 '## ' 직전까지)을 건너뛰면서, 끝의 빈 줄/구분선은 보존한다.
96
- const skipped = [];
97
- while (i < lines.length && !heads.has(i)) {
98
- skipped.push(lines[i]);
99
- i++;
142
+ const hit = cur.sections.find((s) => s.title.startsWith(key));
143
+ const { quotes, tail } = splitTemplateBody(sec.body);
144
+ if (!hit) {
145
+ // 리포가 지운 절은 되살리지 않는다(삭제 의사 존중). 절 사이 구분선만 유지한다.
146
+ omitted.push(key);
147
+ out.push(...tail);
148
+ continue;
149
+ }
150
+ const repoBody = [];
151
+ for (const raw of hit.body) {
152
+ const isQuote = /^\s*>/.test(raw);
153
+ if (isQuote && boiler.has(raw.trim())) {
154
+ dropped.push(raw.trim());
155
+ continue;
156
+ }
157
+ let line = raw;
158
+ for (const [from, to] of DEAD_PATH_REWRITES) {
159
+ if (line.includes(from)) line = line.split(from).join(to);
160
+ }
161
+ if (line !== raw) rewritten.push({ before: raw.trim(), after: line.trim() });
162
+ if (isQuote) kept.push(line.trim());
163
+ repoBody.push(line);
164
+ }
165
+ while (repoBody.length && repoBody[0].trim() === '') repoBody.shift();
166
+ while (
167
+ repoBody.length &&
168
+ (repoBody[repoBody.length - 1].trim() === '' || repoBody[repoBody.length - 1].trim() === '---')
169
+ ) {
170
+ repoBody.pop();
100
171
  }
101
- const tail = [];
102
- while (skipped.length && (skipped[skipped.length - 1].trim() === '' || skipped[skipped.length - 1].trim() === '---')) {
103
- tail.unshift(skipped.pop());
172
+ out.push(sec.heading);
173
+ if (repoBody.length === 0) {
174
+ // 절은 있는데 내용이 비었다 → 새 템플릿 본문(플레이스홀더)을 그대로 쓴다.
175
+ out.push(...sec.body);
176
+ continue;
104
177
  }
105
- out.push('', extracted[key], ...tail);
178
+ if (quotes.length) out.push('', ...quotes);
179
+ out.push('', ...repoBody, ...tail);
180
+ }
181
+
182
+ // 결과물에 실리지 않은 현 문서의 내용 — 리포 값 4개 절 *밖*에 사람이 적어 둔 줄이 여기 걸린다.
183
+ // 그 자리는 taskery 소유라 템플릿 판으로 덮이는 것이 설계지만, 재조립은 매 update 도는 상시
184
+ // 동작이므로 조용히 사라지면 안 된다. 버리되 반드시 알린다(.bak이 원본 참조처).
185
+ const inResult = new Set(out.map((l) => l.trim()));
186
+ const rewrittenBefore = new Set(rewritten.map((r) => r.before));
187
+ const unmapped = [];
188
+ for (const line of currentContent.split('\n')) {
189
+ const l = line.trim();
190
+ if (!l || l === '---') continue;
191
+ if (inResult.has(l) || boiler.has(l) || rewrittenBefore.has(l)) continue;
192
+ unmapped.push(l);
106
193
  }
107
- return out.join('\n');
194
+
195
+ return {
196
+ content: out.join('\n'),
197
+ kept,
198
+ dropped,
199
+ rewritten,
200
+ omitted,
201
+ unmapped,
202
+ missing: omitted.filter((k) => !OPTIONAL_SECTIONS.has(k)),
203
+ };
108
204
  }
109
205
 
110
206
  /**
@@ -128,7 +224,7 @@ function ingestActivePlan(cwd, oldManifest) {
128
224
  * handled — update 본 루프에서 제외할 설치 경로
129
225
  * entries — manifest.files에 기록할 항목 (설치 경로 → { hash, core, managed })
130
226
  */
131
- function migrateEntryDocs(cwd, templateDir, platforms, oldManifest) {
227
+ function recomposeEntryDocs(cwd, templateDir, platforms, oldManifest) {
132
228
  const notes = [];
133
229
  const entries = {};
134
230
  const skip = { handled: [], entries, notes, held: false, done: false };
@@ -137,63 +233,86 @@ function migrateEntryDocs(cwd, templateDir, platforms, oldManifest) {
137
233
  const claudePath = path.join(cwd, 'CLAUDE.md');
138
234
  const hasAgents = fs.existsSync(agentsPath);
139
235
  const hasClaude = fs.existsSync(claudePath);
140
- if (!hasAgents && !hasClaude) return skip;
236
+ // 최초 이행 여부는 manifest 마커 하나로만 판정한다. 파일 구조로 추정하면 플랫폼별로 오판하고
237
+ // (codex 단독은 CLAUDE.md가 없다), 버전으로 거르면 보류된 리포가 영영 재시도되지 않는다.
238
+ const migrated = oldManifest.entryDocs === ENTRY_DOCS_MARKER;
141
239
 
142
- // 이미 이행된 리포는 건너뛴다. 판정은 manifest 마커(ENTRY_DOCS_MARKER) 하나로만 한다.
143
- // - 파일 구조로 추정하면 플랫폼별로 오판한다: codex 단독은 CLAUDE.md가 없어 "이행됨"으로 읽혀
144
- // 구판 AGENTS.md가 본 루프의 customize 프롬프트로 넘어가고 리포 값이 .bak에만 남았다.
145
- // - 버전으로도 거르지 않는다: update는 이행이 보류(held)돼도 manifest 버전을 올리므로
146
- // 버전 게이트를 두면 보류된 리포가 영영 재시도되지 않는다("다음 update가 다시 시도합니다"가 거짓이 된다).
147
- // - 신규 설치는 init이 마커를 박아 두므로 이 한 줄로 걸러진다.
148
- if (oldManifest.entryDocs === ENTRY_DOCS_MARKER) return skip;
149
-
150
- // 소스 선정: AGENTS.md 우선(양쪽은 미러라 값이 같다), 없으면 CLAUDE.md.
151
- const srcPath = hasAgents ? agentsPath : claudePath;
152
- const srcName = hasAgents ? 'AGENTS.md' : 'CLAUDE.md';
153
- const sections = splitSections(fs.readFileSync(srcPath, 'utf8'));
154
-
155
- const extracted = {};
156
- const missing = [];
157
- for (const key of REPO_VALUE_SECTIONS) {
158
- const body = findSectionBody(sections, key);
159
- if (body == null) missing.push(key);
160
- else extracted[key] = body;
161
- }
162
- const fatalMissing = missing.filter((k) => !OPTIONAL_SECTIONS.has(k));
240
+ // 조립 소스: 현 AGENTS.md가 최우선(이행 후의 정상 경로). 없으면 미이행 리포의 구 CLAUDE.md.
241
+ const srcPath = hasAgents ? agentsPath : hasClaude && !migrated ? claudePath : null;
242
+ if (!srcPath) return skip; // 진입 문서 자체가 없음 → 본 루프의 '신규' 분기가 템플릿을 깐다
243
+ const srcName = path.basename(srcPath);
163
244
 
164
245
  const newAgentsSrc = path.join(templateDir, 'AGENTS.md');
165
246
  const newAgents = fs.readFileSync(newAgentsSrc, 'utf8');
247
+ const current = fs.readFileSync(srcPath, 'utf8');
248
+ const composed = composeAgentsDoc(current, newAgents);
166
249
 
167
- if (fatalMissing.length > 0) {
168
- // 값 유실 방지 — 어느 파일도 덮지 않고 새 판을 .new로 남긴다. manifest도 갱신하지 않아
169
- // 다음 update가 다시 시도한다.
250
+ // 최초 이행에서 필수 절을 못 찾으면 소스 판정이 틀렸다는 뜻이다 → 어느 파일도 덮지 않고 보류.
251
+ // (이행 후의 필수 절 부재는 사용자가 지운 것이므로 보류하지 않고 그 삭제를 존중한다.)
252
+ if (!migrated && composed.missing.length > 0) {
170
253
  fs.writeFileSync(path.join(cwd, 'AGENTS.md.new'), newAgents);
171
254
  notes.push(
172
- `⚠ 진입 문서 이행 보류 — ${srcName}에서 필수 섹션을 찾지 못함 (${fatalMissing.join(', ')}).`,
255
+ `⚠ 진입 문서 이행 보류 — ${srcName}에서 필수 섹션을 찾지 못함 (${composed.missing.join(', ')}).`,
173
256
  ` 새 판을 AGENTS.md.new 로 두었습니다. 리포 값을 직접 옮긴 후 AGENTS.md 로 바꿔주세요.`,
174
257
  ` (기존 파일은 그대로 보존되었고 manifest도 갱신하지 않아 다음 update가 다시 시도합니다.)`,
175
258
  );
176
259
  return { handled: ['AGENTS.md', 'CLAUDE.md'], entries, notes, held: true, done: false };
177
260
  }
178
261
 
179
- // AGENTS.md 생성 — 새 템플릿에 리포 값 주입.
180
- // .bak은 *최초 1회만* 남긴다 — 이미 있으면 그게 사용자의 원본이다. 덮으면 원본이 사라진다.
181
- if (fs.existsSync(agentsPath) && !fs.existsSync(agentsPath + '.bak')) {
182
- fs.copyFileSync(agentsPath, agentsPath + '.bak');
262
+ // 결과물이 현 파일과 다를 때만 쓴다. .bak은 *최초 1회만* — 이미 있으면 그게 사용자의 원본이다.
263
+ const changed = !hasAgents || composed.content !== current;
264
+ if (changed) {
265
+ if (hasAgents && !fs.existsSync(agentsPath + '.bak')) {
266
+ fs.copyFileSync(agentsPath, agentsPath + '.bak');
267
+ notes.push(` 직전 AGENTS.md는 AGENTS.md.bak 으로 백업.`);
268
+ }
269
+ fs.writeFileSync(agentsPath, composed.content);
183
270
  }
184
- fs.writeFileSync(agentsPath, injectSections(newAgents, extracted));
185
- entries['AGENTS.md'] = { hash: sha256(newAgentsSrc), core: true, managed: true };
271
+ // manifest에는 **조립 결과물**의 해시를 기록한다. 템플릿 원본 해시를 적으면 실제 파일과
272
+ // 영구 불일치해 매 update마다 '사용자 customize' 오검출이 뜬다(0.7.0 결함).
273
+ entries['AGENTS.md'] = { hash: sha256Text(composed.content), core: true, managed: true };
186
274
 
187
- const injectedCount = REPO_VALUE_SECTIONS.length - missing.length;
188
- notes.push(`진입 문서 재편 — ${srcName}에서 리포 값 ${injectedCount}개 섹션을 AGENTS.md로 이식.`);
189
- if (missing.length > 0) {
190
- notes.push(` (없어서 새 템플릿 기본값으로 둔 절: ${missing.join(', ')})`);
275
+ if (!migrated) {
276
+ notes.push(
277
+ `진입 문서 재편 — ${srcName}의 리포 내용을 새 AGENTS.md로 재조립.`,
278
+ ` 구 문서의 taskery 지침 본문은 새 체계(.project/rules/)로 대체됩니다 — 원본은 ${srcName}.bak.`,
279
+ );
280
+ } else if (changed) {
281
+ notes.push(`AGENTS.md 재조립 — 리포 내용 보존, taskery 지침만 새 판으로 교체.`);
282
+ }
283
+ if (changed) {
284
+ if (composed.dropped.length) {
285
+ notes.push(` 구 taskery 문안 ${composed.dropped.length}줄 제거(새 문안이 대체).`);
286
+ }
287
+ if (composed.kept.length) {
288
+ notes.push(` 리포가 직접 쓴 인용문 ${composed.kept.length}줄 보존.`);
289
+ }
290
+ for (const r of composed.rewritten) {
291
+ notes.push(` 경로 갱신: ${r.before}`);
292
+ notes.push(` → ${r.after}`);
293
+ }
294
+ if (composed.omitted.length) {
295
+ notes.push(` 리포에 없어 결과물에서 뺀 절: ${composed.omitted.join(', ')} (되살리지 않음)`);
296
+ }
297
+ if (composed.unmapped.length && migrated) {
298
+ // 리포 값 칸 밖의 내용은 AGENTS.md가 소유하지 않는다(TASKERY_RULE — 규칙은 rules/, 커스텀은 *.local.md).
299
+ // 그래도 사람이 적어 둔 것이므로 전량을 보여주고 원본 위치를 알린다.
300
+ // 최초 이행에서는 알리지 않는다 — 그때의 미반영분은 전량이 *구 taskery 문서 본문*이라
301
+ // (0.6.1 기준 88줄) 경고가 아니라 노이즈다. 그 경우는 아래 한 줄과 .bak으로 갈음한다.
302
+ notes.push(
303
+ ` ⚠ 리포 값 칸(프로젝트 메타 · 각 명령) 밖의 아래 ${composed.unmapped.length}줄은 결과물에 싣지 않았습니다.`,
304
+ ` 그 자리는 taskery 지침 영역이라 새 판으로 교체됩니다. 남길 내용이면 값 칸이나 *.local.md로 옮기세요.`,
305
+ ` (직전 원본: AGENTS.md.bak)`,
306
+ );
307
+ for (const l of composed.unmapped) notes.push(` · ${l.length > 96 ? l.slice(0, 96) + '…' : l}`);
308
+ }
191
309
  }
192
310
 
193
311
  const handled = ['AGENTS.md'];
194
312
 
195
- // CLAUDE.md는 `@AGENTS.md` 한 줄로 축약 (claude 플랫폼일 때만).
196
- if (platforms.includes('claude')) {
313
+ // CLAUDE.md는 `@AGENTS.md` 한 줄로 축약 — 최초 이행 때 1회(claude 플랫폼일 때만).
314
+ // 이행 후에는 순수 템플릿 파일이라 본 루프가 정상 관리한다.
315
+ if (!migrated && platforms.includes('claude')) {
197
316
  const claudeSrc = path.join(templateDir, 'CLAUDE.md');
198
317
  if (hasClaude && !fs.existsSync(claudePath + '.bak')) {
199
318
  fs.copyFileSync(claudePath, claudePath + '.bak');
@@ -208,6 +327,22 @@ function migrateEntryDocs(cwd, templateDir, platforms, oldManifest) {
208
327
  return { handled, entries, notes, held: false, done: true };
209
328
  }
210
329
 
330
+ /**
331
+ * 2-b. 구형 AGENT-GUIDE.md 정리 — 활성 plan이 manifest로 이관된 뒤에도 남아 있으면
332
+ * 같은 정보를 두 곳에 보유하게 되고, manifest 값이 비는 순간 lib의 구형 폴백이
333
+ * 낡은 값을 되살린다. 삭제는 하지 않고(사용자 파일 불가침) 이름을 바꿔 폴백 경로에서 뺀다.
334
+ * @returns {string|null} 처리 안내 (해당 없으면 null)
335
+ */
336
+ function retireLegacyAgentGuide(cwd, activePlan) {
337
+ if (!activePlan) return null; // manifest가 아직 활성 plan을 못 들었으면 폴백을 남겨둔다
338
+ const guidePath = path.join(cwd, '.project', 'AGENT-GUIDE.md');
339
+ if (!fs.existsSync(guidePath)) return null;
340
+ const retired = guidePath + '.obsolete';
341
+ if (fs.existsSync(retired)) return null;
342
+ fs.renameSync(guidePath, retired);
343
+ return `구형 .project/AGENT-GUIDE.md 는 활성 plan이 manifest로 이관돼 역할이 끝났습니다 — AGENT-GUIDE.md.obsolete 로 이름 변경(내용 보존). 확인 후 삭제해도 됩니다.`;
344
+ }
345
+
211
346
  /**
212
347
  * 3. .gitignore 누락 패턴 — 이미 taskery 패턴을 쓰는 리포에만 제안한다.
213
348
  * (init에서 등록을 거부한 리포는 패턴이 하나도 없으므로 건드리지 않는다.)
@@ -335,10 +470,12 @@ module.exports = {
335
470
  mergeGitignore,
336
471
  isBefore070,
337
472
  splitSections,
338
- findSectionBody,
339
- injectSections,
473
+ parseDoc,
474
+ boilerplateQuotes,
475
+ composeAgentsDoc,
340
476
  ingestActivePlan,
341
- migrateEntryDocs,
477
+ retireLegacyAgentGuide,
478
+ recomposeEntryDocs,
342
479
  planGitignore,
343
480
  planTestGuide,
344
481
  };
package/bin/update.js CHANGED
@@ -42,7 +42,8 @@ const {
42
42
  ENTRY_DOCS_MARKER,
43
43
  mergeGitignore,
44
44
  ingestActivePlan,
45
- migrateEntryDocs,
45
+ retireLegacyAgentGuide,
46
+ recomposeEntryDocs,
46
47
  planGitignore,
47
48
  planTestGuide,
48
49
  } = require('./migrate');
@@ -91,11 +92,11 @@ async function main() {
91
92
  const newFiles = {};
92
93
  const summary = { unchanged: 0, new: 0, autoUpdated: 0, customizedReplaced: 0, skipped: 0, removed: 0 };
93
94
 
94
- // 2.5. 0.7.0 진입 문서 재편 — **본 루프보다 먼저.**
95
- // CLAUDE.md/AGENTS.md를 본 루프에 맡기면 '사용자 customize 검출' 프롬프트가 뜨는데,
96
- // 덮으면 리포 값이 .bak에만 남고 안 덮으면 구판 전문이 새 판과 함께 남아 규칙이 충돌한다.
97
- // 이행이 최종 상태까지 기록하고, 처리한 경로는 아래 루프에서 제외한다.
98
- const entryMig = migrateEntryDocs(cwd, templateDir, platforms, oldManifest);
95
+ // 2.5. 진입 문서 재조립 — **본 루프보다 먼저, 그리고 매 update 항상.**
96
+ // AGENTS.md는 리포 값을 품어 템플릿과 바이트 일치가 원리적으로 불가능하다. 본 루프에 맡기면
97
+ // '사용자 customize 검출'이 떠서, 덮으면 리포 값이 .bak에만 남고 거절하면 구판 문안에 고착된다.
98
+ // 재조립이 최종 상태까지 기록하고, 처리한 경로는 아래 루프에서 제외한다.
99
+ const entryMig = recomposeEntryDocs(cwd, templateDir, platforms, oldManifest);
99
100
  const entryHandled = new Set(entryMig.handled);
100
101
  for (const line of entryMig.notes) console.log(` ${line}`);
101
102
  Object.assign(newFiles, entryMig.entries);
@@ -212,6 +213,8 @@ async function main() {
212
213
  // 소리 없이 사라지고 구형 AGENT-GUIDE 폴백의 낡은 값으로 되돌아간다.
213
214
  const { activePlan, ingested: activePlanIngested } = ingestActivePlan(cwd, oldManifest);
214
215
  if (activePlanIngested) migration.activePlan = true;
216
+ const retiredGuide = retireLegacyAgentGuide(cwd, activePlan);
217
+ if (retiredGuide) console.log(`\n ${retiredGuide}`);
215
218
 
216
219
  const newManifest = {
217
220
  version: getPackageVersion(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angar2/taskery",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Lightweight task system for Claude Code & Codex — 1 메인 세션(또는 멀티세션) + 스킬 9종 + catastrophic hook 안전망 2종",
5
5
  "bin": {
6
6
  "taskery": "bin/taskery.js",