@angar2/taskery 0.8.1 → 1.0.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.
Files changed (61) hide show
  1. package/README.md +437 -188
  2. package/bin/add.js +27 -99
  3. package/bin/backlog.js +240 -113
  4. package/bin/check.js +182 -0
  5. package/bin/commands.js +151 -0
  6. package/bin/finish.js +365 -0
  7. package/bin/init.js +99 -190
  8. package/bin/install.js +143 -0
  9. package/bin/lib.js +591 -1336
  10. package/bin/mcp.js +31 -263
  11. package/bin/orche.js +240 -0
  12. package/bin/plan.js +58 -120
  13. package/bin/prepare.js +329 -0
  14. package/bin/prune.js +46 -176
  15. package/bin/status.js +57 -151
  16. package/bin/taskery.js +76 -120
  17. package/bin/update.js +66 -269
  18. package/package.json +5 -5
  19. package/template/.taskery/BACKLOG.md +38 -0
  20. package/template/.taskery/rules/CHANGELOG_RULE.md +31 -0
  21. package/template/.taskery/rules/DEV_RULE.local.md +8 -0
  22. package/template/.taskery/rules/GIT_RULE.md +82 -0
  23. package/template/.taskery/rules/MOCKUP_RULE.md +19 -0
  24. package/template/.taskery/rules/TASKERY_RULE.md +112 -0
  25. package/template/.taskery/rules/TASK_DOC_RULE.md +104 -0
  26. package/template/.taskery/rules/TEST_RULE.local.md +16 -0
  27. package/template/AGENTS.md +15 -53
  28. package/template/shared/skills/add-backlog/SKILL.md +8 -38
  29. package/template/shared/skills/log-friction/SKILL.md +10 -88
  30. package/template/shared/skills/plan-init/SKILL.md +10 -98
  31. package/template/shared/skills/project-init/SKILL.md +13 -342
  32. package/template/shared/skills/task-close/SKILL.md +16 -319
  33. package/template/shared/skills/task-dev/SKILL.md +19 -233
  34. package/template/shared/skills/task-init/SKILL.md +11 -68
  35. package/template/shared/skills/task-orche/SKILL.md +35 -0
  36. package/template/shared/skills/task-plan/SKILL.md +19 -498
  37. package/template/shared/skills/task-test/SKILL.md +23 -342
  38. package/bin/close.js +0 -86
  39. package/bin/fork.js +0 -112
  40. package/bin/legacy-quotes.js +0 -60
  41. package/bin/migrate.js +0 -481
  42. package/bin/set-status.js +0 -98
  43. package/template/.claude/hooks/closed-immutable.sh +0 -77
  44. package/template/.claude/settings.json +0 -18
  45. package/template/.claude/skills/run-team/SKILL.md +0 -145
  46. package/template/.codex/agents/task-tester.toml +0 -41
  47. package/template/.codex/agents/test-plan-author.toml +0 -34
  48. package/template/.codex/config.toml +0 -17
  49. package/template/.codex/hooks/closed-immutable.sh +0 -66
  50. package/template/.project/changelog/.gitkeep +0 -0
  51. package/template/.project/flows/.gitkeep +0 -0
  52. package/template/.project/plans/.gitkeep +0 -0
  53. package/template/.project/rules/CHANGELOG_RULE.md +0 -97
  54. package/template/.project/rules/GIT_RULE.md +0 -325
  55. package/template/.project/rules/MOCKUP_RULE.md +0 -86
  56. package/template/.project/rules/TASKERY_RULE.md +0 -237
  57. package/template/.project/rules/TASK_DOC_RULE.md +0 -668
  58. package/template/.project/shared/received/completed/.gitkeep +0 -0
  59. package/template/.project/shared/sent/completed/.gitkeep +0 -0
  60. package/template/.project/tasks/.gitkeep +0 -0
  61. package/template/shared/hooks/git-guard.sh +0 -108
package/bin/init.js CHANGED
@@ -1,214 +1,123 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * bin/init.js
4
- * `npx @angar2/taskery init` 본체 — 현재 디렉토리에 template/ 자산 카피 + manifest 생성.
5
- *
6
- * 동작:
7
- * 1. cwd에 .taskery-manifest.json 이미 있으면 경고 + confirm
8
- * 2. template/ 안 *모든 파일* cwd로 카피 (디렉토리 자동 생성)
9
- * 3. *.local.md 사용자 오버라이드 충돌 검사 (덮어쓰지 X)
10
- * 4. .taskery-manifest.json 작성 (version + installed_at + files{path,hash,core,managed} 맵)
11
- * 5. 결과 요약 출력 + 다음 단계 안내 (`/project-init`)
12
- */
13
-
2
+ // init — 리포 루트(또는 빈 폴더)에 taskery 1.0을 설치한다. 빈 폴더면 git 시작·빈 첫 커밋·부모 브랜치까지(§5-3)
14
3
  const fs = require('fs');
15
4
  const path = require('path');
16
- const readline = require('readline');
17
-
18
- const {
19
- MANIFEST_NAME,
20
- findTemplateDir,
21
- walkTemplate,
22
- copyFile,
23
- writeManifest,
24
- readManifest,
25
- getPackageVersion,
26
- isLocalOverride,
27
- generateProjectId,
28
- DEFAULT_STALE_DAYS,
29
- DEFAULT_LOCK_TIMEOUT_MS,
30
- resolveInstallPlan,
31
- gitignorePatternsFor,
32
- } = require('./lib');
5
+ const crypto = require('crypto');
6
+ const L = require('./lib');
7
+ const I = require('./install');
8
+
9
+ async function selectPlatforms(asker) {
10
+ const ans = await asker.ask('에이전트 플랫폼 선택 — 1) Claude Code 2) Codex 3) 둘 다 (기본 1): ', '1');
11
+ if (ans === '2') return ['codex'];
12
+ if (ans === '3') return ['claude', 'codex'];
13
+ return ['claude'];
14
+ }
33
15
 
34
- const { ENTRY_DOCS_MARKER } = require('./migrate');
16
+ // 빈 폴더 — git 시작, exclude 등록, 빈 첫 커밋, 통합 브랜치 만들고 본진을 그 브랜치로
17
+ function startGit(cwd) {
18
+ L.git(cwd, ['init', '-q']);
19
+ L.ensureExclude(cwd);
20
+ L.git(cwd, ['commit', '-q', '--allow-empty', '-m', 'chore: 첫 커밋']);
21
+ const branch = L.parseGitRuleTable(I.readTemplate('.taskery/rules/GIT_RULE.md')).integration || 'dev';
22
+ if (L.currentBranch(cwd) !== branch) L.git(cwd, ['checkout', '-q', '-b', branch]);
23
+ return branch;
24
+ }
35
25
 
36
- async function confirm(msg) {
37
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
- return new Promise((resolve) => {
39
- rl.question(`${msg} (y/N) `, (ans) => {
40
- rl.close();
41
- resolve(ans.trim().toLowerCase() === 'y');
42
- });
43
- });
26
+ // 스택을 보고 빌드 결과 폴더를 제안한다 — 새 워크트리에 APFS 복제할 폴더(§7). Node는 npm ci라 등록하지 않는다
27
+ function proposeBuildOutput(main) {
28
+ const found = [];
29
+ const dirs = ['', ...fs.readdirSync(main, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name)];
30
+ for (const d of dirs) {
31
+ if (fs.existsSync(path.join(main, d, 'Cargo.toml'))) {
32
+ found.push(path.posix.join(d, 'target'));
33
+ if (d === '') break; // 루트 워크스페이스면 target 하나
34
+ }
35
+ }
36
+ if (fs.existsSync(path.join(main, 'Package.swift'))) found.push('.build');
37
+ // Xcode 프로젝트 — 기본 DerivedData는 리포 밖·워크트리마다 다른 곳이라 리포 안 고정 경로로 쓴다
38
+ if (fs.readdirSync(main).some((n) => /\.(xcodeproj|xcworkspace)$/.test(n))) found.push('DerivedData');
39
+ return found;
44
40
  }
45
41
 
46
- // 에이전트 플랫폼 선택 — 고른 것만 독립 설치 (공통 .project/는 항상)
47
- async function selectPlatforms() {
48
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
49
- return new Promise((resolve) => {
50
- rl.question(
51
- `\n에이전트 플랫폼 선택:\n 1) Claude Code\n 2) Codex\n 3) 둘 다\n선택 (1/2/3, 기본 1): `,
52
- (ans) => {
53
- rl.close();
54
- const c = ans.trim() || '1';
55
- if (c === '2') resolve(['codex']);
56
- else if (c === '3') resolve(['claude', 'codex']);
57
- else resolve(['claude']);
58
- },
42
+ function checkRepo(cwd) {
43
+ if (!fs.existsSync(path.join(cwd, '.git')) && !L.gitOk(cwd, ['rev-parse', '--git-dir'])) {
44
+ L.fail('init: git이 없는 폴더에 파일이 이미 있다. 무엇을 첫 커밋에 넣을지(.env 같은 비밀값 포함 여부)는 사용자가 정할 일이라, `git init` 후 첫 커밋을 직접 만든 뒤 다시 부른다.');
45
+ }
46
+ if (!L.gitOk(cwd, ['rev-parse', '--verify', '-q', 'HEAD'])) {
47
+ L.fail('init: 커밋이 없는 리포다. 무엇을 첫 커밋에 넣을지는 사용자가 정할 일이라, 첫 커밋을 직접 만든 뒤 다시 부른다.');
48
+ }
49
+ if (L.findMain(cwd) !== fs.realpathSync(cwd)) {
50
+ L.fail(`init: 리포의 본진 루트에서 부른다 (본진: ${L.findMain(cwd)}).`);
51
+ }
52
+ const tracked = L.git(cwd, ['ls-files', '--', ...L.EXCLUDE_NAMES]);
53
+ if (tracked) {
54
+ const names = [...new Set(tracked.split('\n').map((f) => f.split('/')[0]))];
55
+ L.fail(
56
+ `init: git이 taskery 파일 이름을 이미 추적하고 있어 설치를 멈췄다: ${names.join(', ')}\n` +
57
+ 'taskery 파일은 모두 git 밖에 둔다. 추적 해제는 리포 이력을 바꾸는 일이라 사용자가 한다 — ' +
58
+ '`git rm -r --cached <이름>`으로 추적을 해제해 커밋하거나 파일을 다른 곳으로 옮긴 뒤 다시 부른다.',
59
59
  );
60
- });
60
+ }
61
61
  }
62
62
 
63
63
  async function main() {
64
64
  const cwd = process.cwd();
65
- const templateDir = findTemplateDir();
66
- const manifestPath = path.join(cwd, MANIFEST_NAME);
67
-
68
- // 1. 기존 manifest 검사
69
- if (fs.existsSync(manifestPath)) {
70
- const ok = await confirm(
71
- `${MANIFEST_NAME} 이미 존재 — taskery 이미 설치된 디렉토리. 'update' 사용 권장. 그래도 init 강행할까?`,
72
- );
73
- if (!ok) {
74
- console.log('취소 — 종료.');
75
- process.exit(0);
76
- }
65
+ L.checkLegacyInstall(cwd, 'init');
66
+ if (fs.existsSync(path.join(cwd, L.MANIFEST_NAME))) {
67
+ L.fail('init: taskery가 이미 설치된 리포다. 갱신은 `npx @angar2/taskery update`.');
77
68
  }
78
-
79
- // 2. 플랫폼 선택 + template/ 정독 → 설치 계획 전개 (공통 shared/는 플랫폼 경로로 매핑)
80
- const platforms = await selectPlatforms();
81
- const templateFiles = walkTemplate(templateDir);
82
- const plan = resolveInstallPlan(templateFiles, platforms);
83
-
84
- console.log(`\ntaskery v${getPackageVersion()} init`);
85
- console.log(`대상: ${cwd}`);
86
- console.log(`플랫폼: ${platforms.join(', ')}`);
87
- console.log(`카피 대상: ${plan.length}개 파일\n`);
88
-
89
- // 3. 카피 + manifest 빌드 (manifest 키 = 설치 경로 installRel)
90
- const manifestFiles = {};
91
- let copied = 0;
92
- let skipped = 0;
93
-
94
- for (const { templateRel, installRel, hash } of plan) {
95
- const src = path.join(templateDir, templateRel);
96
- const dst = path.join(cwd, installRel);
97
-
98
- // .local.md suffix는 사용자 영역 — taskery template에는 없어야 정상이지만 방어적으로 스킵
99
- if (isLocalOverride(installRel)) {
100
- console.log(` skip (.local override 영역): ${installRel}`);
101
- skipped++;
102
- continue;
103
- }
104
-
105
- // 사용자 .local.md 충돌 검사 — 사용자가 직접 만든 *.local.md 보호
106
- // 본 init은 신규 설치 default, 충돌 거의 없지만 update 흐름과 일관성 유지
107
- if (fs.existsSync(dst)) {
108
- const ok = await confirm(` '${installRel}' 이미 존재 — 덮어쓸까?`);
109
- if (!ok) {
110
- console.log(` skip: ${installRel}`);
111
- skipped++;
112
- continue;
113
- }
114
- }
115
-
116
- copyFile(src, dst);
117
- manifestFiles[installRel] = { hash, core: true, managed: true };
118
- copied++;
119
- console.log(` copy: ${installRel}`);
120
- }
121
-
122
- // 4. manifest 작성 — 멀티세션(0.1.2+): projectId / stale_days / lock_timeout_ms 포함
123
- const manifest = {
124
- version: getPackageVersion(),
125
- installed_at: new Date().toISOString(),
126
- projectId: generateProjectId(),
127
- platforms,
128
- stale_days: DEFAULT_STALE_DAYS,
129
- lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
130
- activePlan: null, // /plan-init이 채운다 (0.7.0+ 활성 plan SSoT)
131
- entryDocs: ENTRY_DOCS_MARKER, // 신규 설치는 이미 새 진입 문서 체계 — update가 이행을 시도하지 않도록
132
- files: manifestFiles,
133
- };
134
- writeManifest(manifest, manifestPath);
135
-
136
- // 4.5. .gitignore prompt (stash FRICTION_LOG #26 반영)
137
- // 공개 repo면 내부 워크플로 파일 노출 회피 — taskery 내부 영역 .gitignore 등록 제안.
138
- // 사용자 NO면 패스 (그대로 둠). 이미 등록되어 있으면 스킵.
139
- const gitignorePath = path.join(cwd, '.gitignore');
140
- const taskeryPatterns = gitignorePatternsFor(platforms);
141
- const addGitignore = await confirm(
142
- `\ntaskery 내부 파일(${taskeryPatterns.join(', ')})을 .gitignore에 등록할까?\n (공개 repo면 권장 — 내부 워크플로 파일 노출 회피)`,
143
- );
144
- if (addGitignore) {
145
- const existing = fs.existsSync(gitignorePath)
146
- ? fs.readFileSync(gitignorePath, 'utf8').split('\n')
147
- : [];
148
- const existingSet = new Set(existing.map((l) => l.trim()));
149
- const newPatterns = taskeryPatterns.filter((p) => !existingSet.has(p));
150
- if (newPatterns.length > 0) {
151
- const needsLeadingNewline =
152
- existing.length > 0 && existing[existing.length - 1] !== '';
153
- const block = [
154
- needsLeadingNewline ? '' : null,
155
- '# taskery 내부 파일',
156
- ...newPatterns,
157
- '',
158
- ].filter((l) => l !== null);
159
- fs.appendFileSync(gitignorePath, block.join('\n'));
160
- console.log(` .gitignore 갱신 — ${newPatterns.length}개 패턴 추가`);
161
- } else {
162
- console.log(` .gitignore — taskery 패턴 이미 등록됨, 스킵`);
163
- }
69
+ const notes = [];
70
+ if (fs.readdirSync(cwd).length === 0) {
71
+ const branch = startGit(cwd);
72
+ notes.push(`빈 폴더 — git을 시작하고 빈 첫 커밋을 만든 뒤 본진을 ${branch} 브랜치로 옮겼다`);
164
73
  } else {
165
- console.log(` .gitignore — 사용자 선택으로 패스`);
74
+ checkRepo(cwd);
166
75
  }
167
-
168
- // 4.7. MCP 서버 등록 — Claude는 .mcp.json 생성(첫 세션 승인 게이트), Codex는 명령 안내.
169
- // MCP 도구 8종 = lib core의 구조화 입구(CLI와 동일 함수). 미등록 환경은 npx CLI가 폴백.
170
- if (platforms.includes('claude')) {
171
- const mcpJsonPath = path.join(cwd, '.mcp.json');
172
- let mcpConfig = { mcpServers: {} };
173
- if (fs.existsSync(mcpJsonPath)) {
174
- // 기존 .mcp.json의 다른 서버 보존 — taskery 항목만 머지.
175
- try {
176
- mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
177
- } catch (e) {
178
- mcpConfig = { mcpServers: {} };
76
+ const main = L.findMain(cwd);
77
+ const asker = I.makeAsker();
78
+ try {
79
+ const platforms = await selectPlatforms(asker);
80
+ const files = {};
81
+ for (const it of I.installPlan(platforms)) {
82
+ const dst = path.join(main, it.dst);
83
+ if (fs.existsSync(dst)) {
84
+ if (it.kind === 'once') continue;
85
+ const ans = await asker.ask(`'${it.dst}'가 이미 있다 — 덮어쓸까? (y/N) `, 'n');
86
+ if (ans.toLowerCase() !== 'y') {
87
+ notes.push(`건너뜀: ${it.dst} (기존 파일 유지)`);
88
+ continue;
89
+ }
179
90
  }
180
- if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
91
+ const text = I.readTemplate(it.src);
92
+ I.writeFile(main, it.dst, text);
93
+ if (it.kind !== 'once') files[it.dst] = I.hashText(text);
181
94
  }
182
- if (!mcpConfig.mcpServers.taskery) {
183
- mcpConfig.mcpServers.taskery = { command: 'npx', args: ['-y', '@angar2/taskery', 'mcp'] };
184
- fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + '\n');
185
- console.log(` .mcp.json — taskery MCP 서버 등록 (첫 세션에서 승인 필요)`);
186
- } else {
187
- console.log(` .mcp.json — taskery 서버 이미 등록됨, 스킵`);
95
+ notes.push(...I.writeConfigs(main, platforms));
96
+ const buildOutput = proposeBuildOutput(main);
97
+ if (buildOutput.length) {
98
+ notes.push(`빌드 결과 폴더 등록: ${buildOutput.join(', ')} — 새 워크트리에 본진의 이 폴더를 APFS 복제한다(바꾸려면 .taskery/manifest.json의 buildOutput을 고친다)`);
99
+ if (buildOutput.includes('DerivedData')) notes.push('코드 테스트 명령에 -derivedDataPath DerivedData를 붙인다');
188
100
  }
101
+ const added = L.ensureExclude(main, buildOutput);
102
+ if (added.length) notes.push(`.git/info/exclude — ${added.join(' ')}`);
103
+ L.writeManifest(main, {
104
+ version: L.getPackageVersion(),
105
+ installed_at: new Date().toISOString(),
106
+ projectId: crypto.randomBytes(4).toString('hex'),
107
+ platforms,
108
+ files,
109
+ ...(buildOutput.length ? { buildOutput } : {}),
110
+ });
111
+ console.log(`taskery v${L.getPackageVersion()} 설치 완료 — ${main}`);
112
+ console.log(`- 플랫폼: ${platforms.join(', ')}`);
113
+ for (const n of notes) console.log(`- ${n}`);
114
+ console.log('다음: 에이전트 세션에서 project-init 스킬로 프로젝트 정보·용어집·코드 테스트 등록·첫 플랜을 만든다.');
115
+ } finally {
116
+ asker.close();
189
117
  }
190
-
191
- // 5. 결과 보고
192
- console.log(`\n✅ taskery init 완료`);
193
- console.log(` 카피: ${copied}개 / 스킵: ${skipped}개`);
194
- console.log(` 플랫폼: ${platforms.join(', ')}`);
195
- console.log(` manifest: ${MANIFEST_NAME}\n`);
196
- console.log(`다음 단계:`);
197
- let step = 1;
198
- // 진입 문서 본문은 AGENTS.md 한 벌 — CLAUDE.md는 `@AGENTS.md` 임포트 한 줄이라 채울 것이 없다.
199
- console.log(` ${step++}. AGENTS.md 정독 + 프로젝트 메타 + 검증/테스트 명령 채우기`);
200
- console.log(` (.project/rules/TASKERY_RULE.md = taskery 사용 설명서 — 세션이 필독)`);
201
- if (platforms.includes('claude')) {
202
- console.log(` ${step++}. Claude 첫 세션에서 taskery MCP 서버 승인 (.mcp.json — 구조화 도구 8종; 미승인 시 npx CLI 폴백)`);
203
- }
204
- if (platforms.includes('codex')) {
205
- console.log(` ${step++}. Codex 최초 1회 '/hooks'로 hook trust 승인 (.codex/config.toml — git-guard / closed-immutable)`);
206
- console.log(` + MCP 등록: codex mcp add taskery -- npx -y @angar2/taskery mcp`);
207
- }
208
- console.log(` ${step++}. 에이전트 진입 → '/project-init' → '/plan-init <기능그룹>' → '/task-init'\n`);
209
118
  }
210
119
 
211
120
  main().catch((e) => {
212
- console.error(`taskery init 실패: ${e.message}`);
121
+ console.error(e instanceof L.TaskeryError ? e.message : `taskery init 실패: ${e.stack || e.message}`);
213
122
  process.exit(1);
214
123
  });
package/bin/install.js ADDED
@@ -0,0 +1,143 @@
1
+ // 설치·갱신이 함께 쓰는 도구 — 1.0이 설치하는 파일 목록, 설정 파일 병합, AGENTS.md 재조립, 질문 입력
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const readline = require('readline');
6
+ const L = require('./lib');
7
+
8
+ const TEMPLATE = path.resolve(__dirname, '..', 'template');
9
+ const PLATFORMS = ['claude', 'codex'];
10
+ // 1.0 스킬 10개(§5-1)
11
+ const SKILLS = ['task-init', 'task-plan', 'task-dev', 'task-test', 'task-close', 'task-orche', 'project-init', 'plan-init', 'add-backlog', 'log-friction'];
12
+ const RULES = ['TASKERY_RULE.md', 'GIT_RULE.md', 'TASK_DOC_RULE.md', 'CHANGELOG_RULE.md', 'MOCKUP_RULE.md'];
13
+ // 리포가 소유하는 로컬 규칙 틀 — 없을 때만 만들고 update가 덮지 않는다(§6-2)
14
+ const LOCAL_RULES = ['TEST_RULE.local.md', 'DEV_RULE.local.md'];
15
+ const SKILL_ROOT = { claude: '.claude/skills', codex: '.codex/skills' };
16
+
17
+ // kind: managed = 갱신 대상(사용자가 고쳤으면 묻는다) · agents = 프로젝트 절을 살려 재조립 · once = 없을 때만 만든다
18
+ function installPlan(platforms, { agnostic = true } = {}) {
19
+ const plan = [];
20
+ if (agnostic) {
21
+ plan.push({ src: 'AGENTS.md', dst: 'AGENTS.md', kind: 'agents' });
22
+ for (const r of RULES) plan.push({ src: `.taskery/rules/${r}`, dst: `.taskery/rules/${r}`, kind: 'managed' });
23
+ for (const r of LOCAL_RULES) plan.push({ src: `.taskery/rules/${r}`, dst: `.taskery/rules/${r}`, kind: 'once' });
24
+ plan.push({ src: '.taskery/BACKLOG.md', dst: '.taskery/BACKLOG.md', kind: 'once' });
25
+ }
26
+ for (const p of platforms) {
27
+ if (p === 'claude') plan.push({ src: 'CLAUDE.md', dst: 'CLAUDE.md', kind: 'managed' });
28
+ for (const s of SKILLS) plan.push({ src: `shared/skills/${s}/SKILL.md`, dst: `${SKILL_ROOT[p]}/${s}/SKILL.md`, kind: 'managed' });
29
+ }
30
+ return plan;
31
+ }
32
+
33
+ function hashText(text) {
34
+ return 'sha256:' + crypto.createHash('sha256').update(text).digest('hex');
35
+ }
36
+
37
+ function readTemplate(rel) {
38
+ return fs.readFileSync(path.join(TEMPLATE, rel), 'utf8');
39
+ }
40
+
41
+ function writeFile(main, rel, text) {
42
+ const file = path.join(main, rel);
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ fs.writeFileSync(file, text);
45
+ }
46
+
47
+ // AGENTS.md 재조립 — 새 문안에 기존 파일의 `## 프로젝트` 절(project-init이 채운 값)을 그대로 옮긴다
48
+ function recomposeAgents(tpl, current) {
49
+ const grab = (text) => {
50
+ const m = text.match(/^## 프로젝트\n[\s\S]*?(?=^## |(?![\s\S]))/m);
51
+ return m ? m[0] : null;
52
+ };
53
+ const keep = current && grab(current);
54
+ const slot = grab(tpl);
55
+ return keep && slot ? tpl.replace(slot, keep) : tpl;
56
+ }
57
+
58
+ function readJson(file, fallback) {
59
+ if (!fs.existsSync(file)) return fallback;
60
+ try {
61
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ } catch (e) {
63
+ L.fail(`${file}을 JSON으로 읽지 못했다 — 고친 뒤 다시 부른다.`);
64
+ }
65
+ }
66
+
67
+ // 플랫폼 설정 — Claude: 링크 너머 .taskery 쓰기 허용 + MCP 등록 / Codex: MCP 등록(자동 승인)
68
+ function writeConfigs(main, platforms) {
69
+ const notes = [];
70
+ const mcpEntry = { command: 'npx', args: ['-y', '@angar2/taskery', 'mcp'] };
71
+ if (platforms.includes('claude')) {
72
+ const sFile = path.join(main, '.claude', 'settings.json');
73
+ const s = readJson(sFile, {});
74
+ s.permissions = s.permissions || {};
75
+ const dirs = s.permissions.additionalDirectories || [];
76
+ const want = path.join(main, '.taskery');
77
+ if (!dirs.includes(want)) {
78
+ s.permissions.additionalDirectories = [...dirs, want];
79
+ fs.mkdirSync(path.dirname(sFile), { recursive: true });
80
+ fs.writeFileSync(sFile, JSON.stringify(s, null, 2) + '\n');
81
+ notes.push('.claude/settings.json — 링크 너머 .taskery 쓰기 허용(additionalDirectories)');
82
+ }
83
+ const mFile = path.join(main, '.mcp.json');
84
+ const m = readJson(mFile, { mcpServers: {} });
85
+ m.mcpServers = m.mcpServers || {};
86
+ if (!m.mcpServers.taskery) {
87
+ m.mcpServers.taskery = mcpEntry;
88
+ fs.writeFileSync(mFile, JSON.stringify(m, null, 2) + '\n');
89
+ notes.push('.mcp.json — taskery MCP 서버 등록');
90
+ }
91
+ }
92
+ if (platforms.includes('codex')) {
93
+ const cFile = path.join(main, '.codex', 'config.toml');
94
+ const cur = fs.existsSync(cFile) ? fs.readFileSync(cFile, 'utf8') : '';
95
+ if (!/^\[mcp_servers\.taskery\]/m.test(cur)) {
96
+ const block = [
97
+ '[mcp_servers.taskery]',
98
+ 'command = "npx"',
99
+ 'args = ["-y", "@angar2/taskery", "mcp"]',
100
+ 'default_tools_approval_mode = "approve"',
101
+ 'tool_timeout_sec = 3600',
102
+ '',
103
+ ].join('\n');
104
+ fs.mkdirSync(path.dirname(cFile), { recursive: true });
105
+ fs.writeFileSync(cFile, cur ? `${cur.replace(/\s*$/, '')}\n\n${block}` : block);
106
+ notes.push('.codex/config.toml — taskery MCP 서버 등록(자동 승인 approve)');
107
+ }
108
+ }
109
+ return notes;
110
+ }
111
+
112
+ // 질문 입력 — 표준 입력의 줄을 차례로 받는다. 입력이 끝났으면 기본값을 쓴다
113
+ function makeAsker() {
114
+ const queue = [];
115
+ const waiters = [];
116
+ let closed = false;
117
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
118
+ rl.on('line', (line) => (waiters.length ? waiters.shift()(line) : queue.push(line)));
119
+ rl.on('close', () => {
120
+ closed = true;
121
+ while (waiters.length) waiters.shift()(null);
122
+ });
123
+ return {
124
+ ask(question, def) {
125
+ process.stdout.write(question);
126
+ return new Promise((resolve) => {
127
+ const done = (line) => {
128
+ const v = line == null || line.trim() === '' ? def : line.trim();
129
+ if (line == null) process.stdout.write('\n');
130
+ resolve(v);
131
+ };
132
+ if (queue.length) done(queue.shift());
133
+ else if (closed) done(null);
134
+ else waiters.push(done);
135
+ });
136
+ },
137
+ close() {
138
+ rl.close();
139
+ },
140
+ };
141
+ }
142
+
143
+ module.exports = { TEMPLATE, PLATFORMS, SKILLS, installPlan, hashText, readTemplate, writeFile, recomposeAgents, writeConfigs, makeAsker };