aizuchi 0.1.0
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/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/bin/aizuchi.js +546 -0
- package/dist/server/app.js +817 -0
- package/dist/server/board.js +248 -0
- package/dist/server/events.js +24 -0
- package/dist/server/lib/config-paths.js +91 -0
- package/dist/server/lib/docs.js +154 -0
- package/dist/server/lib/files.js +208 -0
- package/dist/server/lib/fs-safe.js +428 -0
- package/dist/server/lib/git.js +100 -0
- package/dist/server/lib/id.js +54 -0
- package/dist/server/lib/pinpoint.js +225 -0
- package/dist/server/lib/proposal.js +93 -0
- package/dist/server/lib/task-file.js +132 -0
- package/dist/server/lib/task-ops.js +206 -0
- package/dist/server/lib/time.js +24 -0
- package/dist/server/overview.js +119 -0
- package/dist/server/static.js +71 -0
- package/dist/server/watcher.js +38 -0
- package/dist/shared/critical-path.js +128 -0
- package/dist/shared/diff.js +46 -0
- package/dist/shared/digest.js +44 -0
- package/dist/shared/milestone.js +29 -0
- package/dist/shared/related-docs.js +77 -0
- package/dist/shared/schema.js +376 -0
- package/dist/shared/stats.js +167 -0
- package/dist/web/assets/index-BLN_aXBe.css +1 -0
- package/dist/web/assets/index-BS_n8d9B.js +82 -0
- package/dist/web/index.html +13 -0
- package/package.json +73 -0
- package/templates/claude-rules.md +85 -0
- package/templates/config.json +28 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, realpathSync, promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { DEFAULT_OVERVIEW_PORT, DEFAULT_PORT } from '../shared/schema.js';
|
|
8
|
+
import { createApp } from '../server/app.js';
|
|
9
|
+
import { loadConfig } from '../server/board.js';
|
|
10
|
+
import { createEventBus } from '../server/events.js';
|
|
11
|
+
import { cleanupStaleTmpFiles, resolveKanbanReal, writeFileAtomic } from '../server/lib/fs-safe.js';
|
|
12
|
+
import { createOverviewApp } from '../server/overview.js';
|
|
13
|
+
import { attachStaticAssets } from '../server/static.js';
|
|
14
|
+
import { startWatcher } from '../server/watcher.js';
|
|
15
|
+
/**
|
|
16
|
+
* AIzuchi CLI (要件 §4.3)。
|
|
17
|
+
* - init : .kanban/ を生成し、CLAUDE.md へ運用規約をマーカー区切りで追記する (N-9: 冪等)
|
|
18
|
+
* - serve: 127.0.0.1 限定でボードを起動する (--port > config > 5151)
|
|
19
|
+
* - overview: 複数 .kanban の read-only 集約を起動する (--port > 5152、波 VII)
|
|
20
|
+
*/
|
|
21
|
+
export const MARKER_BEGIN = '<!-- aizuchi:begin -->';
|
|
22
|
+
export const MARKER_END = '<!-- aizuchi:end -->';
|
|
23
|
+
/** .kanban/ 直下に置く説明ファイル (要件 §4.3 の README) */
|
|
24
|
+
const KANBAN_README = `# .kanban — AIzuchi タスクボード
|
|
25
|
+
|
|
26
|
+
このディレクトリは AIzuchi (カンバンダッシュボード) のデータ置き場です。
|
|
27
|
+
|
|
28
|
+
- \`config.json\` — ボード設定 (メンバー・カラム・ID 接頭辞・指示テンプレート)
|
|
29
|
+
- \`tasks/\` — アクティブなタスク (1 タスク = 1 Markdown ファイル、ID = ファイル名)
|
|
30
|
+
- \`archive/\` — 完了・却下タスク (アーカイブ実行月の月別フォルダ)
|
|
31
|
+
|
|
32
|
+
タスクの書式・運用規約はプロジェクトの CLAUDE.md「AIzuchi 運用規約」を参照してください。
|
|
33
|
+
`;
|
|
34
|
+
/** templates/ を持つパッケージルートを特定する (bin/ と dist/bin/ の両方から動くようにする) */
|
|
35
|
+
export function packageRoot() {
|
|
36
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (existsSync(path.join(dir, 'templates')))
|
|
39
|
+
return dir;
|
|
40
|
+
const parent = path.dirname(dir);
|
|
41
|
+
if (parent === dir) {
|
|
42
|
+
throw new Error('パッケージルート (templates/ を含むディレクトリ) が見つからない');
|
|
43
|
+
}
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* メンバー ID の形式 (要件 §5.2 の例に準拠: 英数字・アンダースコア・ハイフン)。
|
|
49
|
+
* config.json の members.id / created_by / assignee と同じ字種に揃える。
|
|
50
|
+
*/
|
|
51
|
+
export const MEMBER_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
52
|
+
/** メンバー ID を検証する。不正なら例外 (CLI 引数の早期バリデーション) */
|
|
53
|
+
export function assertValidMemberId(memberId) {
|
|
54
|
+
if (!MEMBER_ID_RE.test(memberId)) {
|
|
55
|
+
throw new Error(`--member の ID は英数字・アンダースコア・ハイフンのみ使えます: ${JSON.stringify(memberId)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** テンプレートのメンバー ID 欄を埋める */
|
|
59
|
+
export function renderRulesSection(template, memberId) {
|
|
60
|
+
// replacement に関数を使う: 文字列だと memberId 中の `$&` 等が置換特殊パターンとして解釈される
|
|
61
|
+
const body = template.replaceAll('{{MEMBER_ID}}', () => memberId).trimEnd();
|
|
62
|
+
return `${MARKER_BEGIN}\n${body}\n${MARKER_END}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* CLAUDE.md へ規約セクションを適用する (N-9)。
|
|
66
|
+
* - マーカー区間が既にあれば、その区間だけを置き換える (冪等)
|
|
67
|
+
* - 無ければ末尾に追記、ファイル自体が無ければ新規内容を返す
|
|
68
|
+
*/
|
|
69
|
+
export function applyRulesSection(existing, section) {
|
|
70
|
+
if (existing === null) {
|
|
71
|
+
return `${section}\n`;
|
|
72
|
+
}
|
|
73
|
+
const begin = existing.indexOf(MARKER_BEGIN);
|
|
74
|
+
const end = existing.indexOf(MARKER_END);
|
|
75
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
76
|
+
return existing.slice(0, begin) + section + existing.slice(end + MARKER_END.length);
|
|
77
|
+
}
|
|
78
|
+
const separator = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
79
|
+
return `${existing}${separator}${section}\n`;
|
|
80
|
+
}
|
|
81
|
+
export async function runInit(options) {
|
|
82
|
+
const cwd = path.resolve(options.cwd);
|
|
83
|
+
const memberId = options.memberId ?? 'claude';
|
|
84
|
+
assertValidMemberId(memberId);
|
|
85
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
86
|
+
const confirm = options.confirm ?? (async () => true);
|
|
87
|
+
const kanbanDir = path.join(cwd, '.kanban');
|
|
88
|
+
if (existsSync(kanbanDir)) {
|
|
89
|
+
// 既存データを壊さないため中断する (要件 §4.3)。既存ファイルには一切触れない
|
|
90
|
+
throw new Error('.kanban/ が既に存在するため中断しました。再セットアップする場合は .kanban/ を退避してください');
|
|
91
|
+
}
|
|
92
|
+
// 1. .kanban/ 一式の生成
|
|
93
|
+
await fs.mkdir(path.join(kanbanDir, 'tasks'), { recursive: true });
|
|
94
|
+
await fs.mkdir(path.join(kanbanDir, 'archive'), { recursive: true });
|
|
95
|
+
const templateRaw = await fs.readFile(path.join(packageRoot(), 'templates', 'config.json'), 'utf8');
|
|
96
|
+
const config = JSON.parse(templateRaw);
|
|
97
|
+
config.projectName = path.basename(cwd);
|
|
98
|
+
// --member の ID を config.members へ ai 種別で登録する (要件 §8)。さもないと AI が自分の ID で
|
|
99
|
+
// 起票した瞬間「members 未登録」警告に落ち、AI 連携の入口が壊れる (R3-5)
|
|
100
|
+
const members = Array.isArray(config.members) ? config.members : [];
|
|
101
|
+
if (!members.some((m) => m.id === memberId)) {
|
|
102
|
+
members.push({ id: memberId, type: 'ai' });
|
|
103
|
+
config.members = members;
|
|
104
|
+
log(`メンバー "${memberId}" (ai) を config.members に追加しました`);
|
|
105
|
+
}
|
|
106
|
+
await writeFileAtomic(path.join(kanbanDir, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
107
|
+
await writeFileAtomic(path.join(kanbanDir, 'README.md'), KANBAN_README);
|
|
108
|
+
log(`.kanban/ を生成しました (project: ${String(config.projectName)})`);
|
|
109
|
+
// 2. CLAUDE.md への運用規約追記 (マーカー区切り・diff 提示 + 確認)
|
|
110
|
+
const rulesTemplate = await fs.readFile(path.join(packageRoot(), 'templates', 'claude-rules.md'), 'utf8');
|
|
111
|
+
const section = renderRulesSection(rulesTemplate, memberId);
|
|
112
|
+
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
|
113
|
+
const existing = existsSync(claudeMdPath) ? await fs.readFile(claudeMdPath, 'utf8') : null;
|
|
114
|
+
const next = applyRulesSection(existing, section);
|
|
115
|
+
let claudeMdUpdated = false;
|
|
116
|
+
if (next === existing) {
|
|
117
|
+
log('CLAUDE.md は既に最新の運用規約を含んでいます (変更なし)');
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const action = existing === null
|
|
121
|
+
? 'CLAUDE.md を新規作成し、運用規約セクションを書き込みます'
|
|
122
|
+
: existing.includes(MARKER_BEGIN)
|
|
123
|
+
? 'CLAUDE.md のマーカー区間 (aizuchi:begin〜end) だけを置き換えます'
|
|
124
|
+
: 'CLAUDE.md の末尾に運用規約セクションを追記します';
|
|
125
|
+
const preview = [`${action}:`, '--- 追記/置換されるセクション ---', section, '--- ここまで ---'].join('\n');
|
|
126
|
+
if (await confirm(preview)) {
|
|
127
|
+
await writeFileAtomic(claudeMdPath, next);
|
|
128
|
+
claudeMdUpdated = true;
|
|
129
|
+
log(`CLAUDE.md を更新しました (メンバー ID: ${memberId})`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
log('CLAUDE.md の更新をスキップしました (.kanban/ は生成済み)');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
log('完了。`npx aizuchi` でボードを起動できます');
|
|
136
|
+
return { kanbanCreated: true, claudeMdUpdated };
|
|
137
|
+
}
|
|
138
|
+
export async function runServe(options) {
|
|
139
|
+
const cwd = path.resolve(options.cwd);
|
|
140
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
141
|
+
const kanbanDir = path.join(cwd, '.kanban');
|
|
142
|
+
if (!existsSync(kanbanDir)) {
|
|
143
|
+
throw new Error('.kanban/ が見つかりません。先に `npx aizuchi init` を実行してください');
|
|
144
|
+
}
|
|
145
|
+
// クラッシュ時の一時ファイル残骸を清掃する (R17-D: .kanban の realpath 信頼境界内に限定削除)。
|
|
146
|
+
// .kanban が symlink 等で realpath 確立に失敗したら cleanup を skip する (任意ファイル削除の防止)
|
|
147
|
+
try {
|
|
148
|
+
const kanbanReal = await resolveKanbanReal(cwd);
|
|
149
|
+
const removed = await cleanupStaleTmpFiles(kanbanReal, kanbanReal);
|
|
150
|
+
if (removed > 0) {
|
|
151
|
+
log(`一時ファイルの残骸を ${removed} 件清掃しました`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// .kanban が symlink / 不在: cleanup を skip (containment 外の削除はしない)
|
|
156
|
+
}
|
|
157
|
+
const { config } = await loadConfig(cwd);
|
|
158
|
+
const port = options.port ?? config.port ?? DEFAULT_PORT;
|
|
159
|
+
const bus = createEventBus();
|
|
160
|
+
const { app } = createApp({ projectRoot: cwd, bus });
|
|
161
|
+
attachStaticAssets(app, path.join(packageRoot(), 'dist', 'web'));
|
|
162
|
+
const watcher = startWatcher({ kanbanDir, bus });
|
|
163
|
+
const { server, boundPort } = await new Promise((resolve, reject) => {
|
|
164
|
+
// 127.0.0.1 限定でバインドする (N-1)。0.0.0.0 や LAN IP への変更手段は提供しない。
|
|
165
|
+
// info.port は実際にバインドされたポート (port=0 のときは OS が選んだ番号が入る)
|
|
166
|
+
const created = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => resolve({ server: created, boundPort: info.port }));
|
|
167
|
+
created.on('error', (error) => {
|
|
168
|
+
if (error.code === 'EADDRINUSE') {
|
|
169
|
+
// 自動でポートを変えず、明確なエラーで終了する (決定済み)
|
|
170
|
+
reject(new Error(`ポート ${port} は使用中です。--port <番号> か .kanban/config.json の port で変更してください`));
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
reject(error);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}).catch(async (error) => {
|
|
177
|
+
await watcher.close();
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
log(`AIzuchi: http://127.0.0.1:${boundPort} (project: ${config.projectName})`);
|
|
181
|
+
log('終了するには Ctrl+C');
|
|
182
|
+
return {
|
|
183
|
+
port: boundPort,
|
|
184
|
+
async close() {
|
|
185
|
+
await watcher.close();
|
|
186
|
+
// 活きた SSE 接続 (GET /api/events) があると server.close() の callback が永久に呼ばれない
|
|
187
|
+
// (http.Server.close は全接続が閉じるまで待つ)。先に全接続を強制クローズしてから close する
|
|
188
|
+
// (Node 18.2+ の closeAllConnections。M4 で EventSource が常時 1 本開くため必須)。
|
|
189
|
+
// @hono/node-server の ServerType union には型が無いため http.Server として参照する
|
|
190
|
+
const closable = server;
|
|
191
|
+
closable.closeAllConnections?.();
|
|
192
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// overview モード (波 VII): 複数 .kanban の read-only 集約
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* overview の対象ディレクトリを検証して realpath の配列を返す。
|
|
201
|
+
* 各 dir: 正規化 + realpath 解決 → <dir>/.kanban の存在を検証。
|
|
202
|
+
* 無い dir は明確なエラーで起動拒否 (typo の silent skip は混乱の元・fail-fast)。0 件は usage エラー。
|
|
203
|
+
* R12-1: 同一 realpath の重複指定 (相対/絶対・末尾スラッシュ・symlink 別名) も明確なエラーで拒否する
|
|
204
|
+
* (silent 排除だと「2 件指定したのに 1 枚」の混乱、放置だとカード偽装 + 二重計上 + watcher 二重)。
|
|
205
|
+
*/
|
|
206
|
+
export function resolveOverviewRoots(dirs) {
|
|
207
|
+
if (dirs.length === 0) {
|
|
208
|
+
throw new Error('使い方: aizuchi overview <dir...> [--port <番号>] (対象ディレクトリを 1 つ以上指定してください)');
|
|
209
|
+
}
|
|
210
|
+
const seen = new Map(); // realpath → 最初に指定された表記
|
|
211
|
+
return dirs.map((dir) => {
|
|
212
|
+
const resolved = path.resolve(dir);
|
|
213
|
+
let real;
|
|
214
|
+
try {
|
|
215
|
+
real = realpathSync(resolved);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new Error(`ディレクトリが存在しません: ${dir}`);
|
|
219
|
+
}
|
|
220
|
+
if (!existsSync(path.join(real, '.kanban'))) {
|
|
221
|
+
throw new Error(`.kanban/ が見つかりません: ${dir} (aizuchi init 済みのプロジェクトを指定してください)`);
|
|
222
|
+
}
|
|
223
|
+
const first = seen.get(real);
|
|
224
|
+
if (first !== undefined) {
|
|
225
|
+
throw new Error(`同じディレクトリが重複指定されています: ${first} と ${dir} (実体: ${real})`);
|
|
226
|
+
}
|
|
227
|
+
seen.set(real, dir);
|
|
228
|
+
return real;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* overview の引数解析 (R12-2)。位置引数 (dir...) + --port のみを受け付け、
|
|
233
|
+
* 不明なオプション / フラグは fail-fast で拒否する (typo の silent 無視や
|
|
234
|
+
* `--foo ./b` が ./b を値として飲み込み監視対象が黙って脱落する事故を防ぐ)。
|
|
235
|
+
*/
|
|
236
|
+
export function parseOverviewArgs(args) {
|
|
237
|
+
const { positional, opts, flags } = parseTaskArgs(args);
|
|
238
|
+
for (const key of opts.keys()) {
|
|
239
|
+
if (key !== 'port') {
|
|
240
|
+
throw new Error(`不明な引数: --${key} (overview は <dir...> と --port のみ受け付けます)`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const unknownFlag = [...flags][0];
|
|
244
|
+
if (unknownFlag !== undefined) {
|
|
245
|
+
throw new Error(`不明な引数: --${unknownFlag} (overview は <dir...> と --port のみ受け付けます)`);
|
|
246
|
+
}
|
|
247
|
+
let port;
|
|
248
|
+
const portValue = opts.get('port');
|
|
249
|
+
if (portValue !== undefined) {
|
|
250
|
+
port = Number.parseInt(portValue, 10);
|
|
251
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
252
|
+
throw new Error('--port には 1〜65535 の整数を指定してください');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { dirs: positional, port };
|
|
256
|
+
}
|
|
257
|
+
export async function runOverview(options) {
|
|
258
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
259
|
+
const roots = resolveOverviewRoots(options.dirs);
|
|
260
|
+
const port = options.port ?? DEFAULT_OVERVIEW_PORT;
|
|
261
|
+
const bus = createEventBus();
|
|
262
|
+
const { app } = createOverviewApp({ projectRoots: roots, bus });
|
|
263
|
+
attachStaticAssets(app, path.join(packageRoot(), 'dist', 'web'));
|
|
264
|
+
// ルートごとに watcher を起動し共有 bus へ notify (SSE → クライアントは /api/overview を全量再取得)
|
|
265
|
+
const watchers = roots.map((root) => startWatcher({ kanbanDir: path.join(root, '.kanban'), bus }));
|
|
266
|
+
const { server, boundPort } = await new Promise((resolve, reject) => {
|
|
267
|
+
// 127.0.0.1 限定でバインドする (N-1)。serve と同じ起動コード
|
|
268
|
+
const created = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => resolve({ server: created, boundPort: info.port }));
|
|
269
|
+
created.on('error', (error) => {
|
|
270
|
+
if (error.code === 'EADDRINUSE') {
|
|
271
|
+
reject(new Error(`ポート ${port} は使用中です。--port <番号> で変更してください`));
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
reject(error);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}).catch(async (error) => {
|
|
278
|
+
await Promise.all(watchers.map((w) => w.close()));
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
log(`AIzuchi overview: http://127.0.0.1:${boundPort} (${roots.length} project(s))`);
|
|
282
|
+
log('終了するには Ctrl+C');
|
|
283
|
+
return {
|
|
284
|
+
port: boundPort,
|
|
285
|
+
async close() {
|
|
286
|
+
// 全 watcher close + SSE 接続の強制クローズ (runServe と同じ理由)
|
|
287
|
+
await Promise.all(watchers.map((w) => w.close()));
|
|
288
|
+
const closable = server;
|
|
289
|
+
closable.closeAllConnections?.();
|
|
290
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function parseArgs(argv) {
|
|
295
|
+
const args = [...argv];
|
|
296
|
+
let command = 'serve';
|
|
297
|
+
if (args[0] !== undefined && !args[0].startsWith('-')) {
|
|
298
|
+
command = args.shift();
|
|
299
|
+
}
|
|
300
|
+
let port;
|
|
301
|
+
let memberId;
|
|
302
|
+
let yes = false;
|
|
303
|
+
while (args.length > 0) {
|
|
304
|
+
const arg = args.shift();
|
|
305
|
+
if (arg === '--port') {
|
|
306
|
+
port = Number.parseInt(args.shift() ?? '', 10);
|
|
307
|
+
}
|
|
308
|
+
else if (arg.startsWith('--port=')) {
|
|
309
|
+
port = Number.parseInt(arg.slice('--port='.length), 10);
|
|
310
|
+
}
|
|
311
|
+
else if (arg === '--member') {
|
|
312
|
+
memberId = args.shift();
|
|
313
|
+
}
|
|
314
|
+
else if (arg.startsWith('--member=')) {
|
|
315
|
+
memberId = arg.slice('--member='.length);
|
|
316
|
+
}
|
|
317
|
+
else if (arg === '--yes' || arg === '-y') {
|
|
318
|
+
yes = true;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
throw new Error(`不明な引数: ${arg}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
325
|
+
throw new Error('--port には 1〜65535 の整数を指定してください');
|
|
326
|
+
}
|
|
327
|
+
return { command, port, memberId, yes };
|
|
328
|
+
}
|
|
329
|
+
/** 対話確認 (TTY のみ。--yes か非対話環境では確認を省略する) */
|
|
330
|
+
async function promptConfirm(message) {
|
|
331
|
+
console.log(message);
|
|
332
|
+
if (!process.stdin.isTTY) {
|
|
333
|
+
console.log('(非対話環境のため確認を省略して続行します)');
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
337
|
+
try {
|
|
338
|
+
const answer = await new Promise((resolve) => {
|
|
339
|
+
rl.question('CLAUDE.md を更新しますか? [Y/n] ', resolve);
|
|
340
|
+
});
|
|
341
|
+
return answer.trim() === '' || /^y(es)?$/i.test(answer.trim());
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
rl.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// AI 用 CLI (F-6-3): task add/move/log/comment/done/block
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/** 値を取らない真の flag (それ以外の `--key` は値必須として扱う)。`--json` のみ */
|
|
351
|
+
const TASK_BARE_FLAGS = new Set(['json']);
|
|
352
|
+
/** `--key value` / `--key=value` / `--flag` を解析する簡易パーサ (positional は順に取り出す) */
|
|
353
|
+
function parseTaskArgs(args) {
|
|
354
|
+
const positional = [];
|
|
355
|
+
const opts = new Map();
|
|
356
|
+
const flags = new Set();
|
|
357
|
+
const rest = [...args];
|
|
358
|
+
while (rest.length > 0) {
|
|
359
|
+
const arg = rest.shift();
|
|
360
|
+
if (arg.startsWith('--')) {
|
|
361
|
+
const eq = arg.indexOf('=');
|
|
362
|
+
if (eq !== -1) {
|
|
363
|
+
// `--key=value` は値が `--` で始まっても明示指定として受け取る (取りこぼし回避の唯一の経路)
|
|
364
|
+
opts.set(arg.slice(2, eq), arg.slice(eq + 1));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const key = arg.slice(2);
|
|
368
|
+
if (TASK_BARE_FLAGS.has(key)) {
|
|
369
|
+
flags.add(key);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// 値を取るオプション。次トークンが `--` 始まり (別オプション) だと、以前は黙って flag 扱いに
|
|
373
|
+
// なり値を取りこぼしていた。明示エラーにして、`--key=--value` 形式を使うよう促す
|
|
374
|
+
const next = rest[0];
|
|
375
|
+
if (next === undefined || next.startsWith('--')) {
|
|
376
|
+
throw new Error(`--${key} には値が必要です (値が "--" で始まる場合は --${key}=<値> 形式で指定してください)`);
|
|
377
|
+
}
|
|
378
|
+
opts.set(key, rest.shift());
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
positional.push(arg);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { positional, opts, flags };
|
|
387
|
+
}
|
|
388
|
+
/** cwd (または --cwd) から上方向に .kanban を探してプロジェクトルートを返す。無ければ throw */
|
|
389
|
+
async function resolveProjectRoot(startDir) {
|
|
390
|
+
let dir = path.resolve(startDir);
|
|
391
|
+
for (;;) {
|
|
392
|
+
if (existsSync(path.join(dir, '.kanban')))
|
|
393
|
+
return dir;
|
|
394
|
+
const parent = path.dirname(dir);
|
|
395
|
+
if (parent === dir) {
|
|
396
|
+
throw new Error('.kanban/ が見つかりません (aizuchi init を実行したディレクトリで実行してください)');
|
|
397
|
+
}
|
|
398
|
+
dir = parent;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* `task` サブコマンドを実行する。出力は人間可読 + `--json` で機械可読。
|
|
403
|
+
* 既存の TaskOps (schema 検証・採番直列化・pinpoint+verify・パス検証) を呼ぶだけの薄いラッパー。
|
|
404
|
+
* 戻り値は exit code (0 = 成功 / 1 = 入力エラー / 2 = not found / 3 = conflict)。
|
|
405
|
+
*/
|
|
406
|
+
export async function runTaskCli(argv, io = {
|
|
407
|
+
out: (s) => console.log(s),
|
|
408
|
+
err: (s) => console.error(s),
|
|
409
|
+
}) {
|
|
410
|
+
const { TaskOps, TaskOpError } = await import('../server/lib/task-ops.js');
|
|
411
|
+
const sub = argv[0];
|
|
412
|
+
// 引数解析エラー (値の取りこぼし等) も catch で exit 1 + io.err に倒すため try 内で解析する。
|
|
413
|
+
// json は catch でも使うので false で初期化し、解析後に確定する
|
|
414
|
+
let json = false;
|
|
415
|
+
const splitLabels = (v) => v === undefined ? undefined : v.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
416
|
+
try {
|
|
417
|
+
const { positional, opts, flags } = parseTaskArgs(argv.slice(1));
|
|
418
|
+
json = flags.has('json');
|
|
419
|
+
const root = await resolveProjectRoot(opts.get('cwd') ?? process.cwd());
|
|
420
|
+
const ops = new TaskOps(root);
|
|
421
|
+
const emit = (view, human) => {
|
|
422
|
+
if (json)
|
|
423
|
+
io.out(JSON.stringify(view));
|
|
424
|
+
else
|
|
425
|
+
io.out(human);
|
|
426
|
+
return 0;
|
|
427
|
+
};
|
|
428
|
+
switch (sub) {
|
|
429
|
+
case 'add': {
|
|
430
|
+
const view = await ops.create({
|
|
431
|
+
title: opts.get('title') ?? '',
|
|
432
|
+
created_by: opts.get('created-by') ?? 'claude',
|
|
433
|
+
status: opts.get('status'),
|
|
434
|
+
assignee: opts.get('assignee') ?? null,
|
|
435
|
+
priority: opts.get('priority'),
|
|
436
|
+
labels: splitLabels(opts.get('labels')),
|
|
437
|
+
});
|
|
438
|
+
return emit(view, `起票: ${view.frontmatter.id} (${view.frontmatter.status}) ${view.frontmatter.title}`);
|
|
439
|
+
}
|
|
440
|
+
case 'move': {
|
|
441
|
+
const view = await ops.move(positional[0] ?? '', {
|
|
442
|
+
status: positional[1],
|
|
443
|
+
blocked_type: opts.get('blocked-type'),
|
|
444
|
+
blocked_reason: opts.get('blocked-reason') ?? undefined,
|
|
445
|
+
});
|
|
446
|
+
return emit(view, `移動: ${view.frontmatter.id} → ${view.frontmatter.status}`);
|
|
447
|
+
}
|
|
448
|
+
case 'done': {
|
|
449
|
+
const view = await ops.move(positional[0] ?? '', { status: 'done' });
|
|
450
|
+
return emit(view, `完了: ${view.frontmatter.id}`);
|
|
451
|
+
}
|
|
452
|
+
case 'block': {
|
|
453
|
+
const view = await ops.move(positional[0] ?? '', {
|
|
454
|
+
status: 'pending',
|
|
455
|
+
blocked_type: opts.get('type'),
|
|
456
|
+
blocked_reason: opts.get('reason') ?? undefined,
|
|
457
|
+
});
|
|
458
|
+
return emit(view, `保留: ${view.frontmatter.id} (${view.frontmatter.blocked_type ?? ''})`);
|
|
459
|
+
}
|
|
460
|
+
case 'log': {
|
|
461
|
+
const view = await ops.appendLog(positional[0] ?? '', opts.get('member') ?? 'claude', positional[1] ?? '');
|
|
462
|
+
return emit(view, `作業ログ追記: ${view.frontmatter.id}`);
|
|
463
|
+
}
|
|
464
|
+
case 'comment': {
|
|
465
|
+
const view = await ops.appendComment(positional[0] ?? '', opts.get('member') ?? 'claude', positional[1] ?? '');
|
|
466
|
+
return emit(view, `コメント追記: ${view.frontmatter.id}`);
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
io.err('task サブコマンド: add / move <id> <status> / done <id> / block <id> --type --reason / log <id> "msg" / comment <id> "msg"');
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error instanceof TaskOpError) {
|
|
475
|
+
const body = json
|
|
476
|
+
? JSON.stringify({ error: error.message, code: error.code, ...(error.issues ? { issues: error.issues } : {}) })
|
|
477
|
+
: `${error.message}${error.issues ? `\n - ${error.issues.join('\n - ')}` : ''}`;
|
|
478
|
+
io.err(body);
|
|
479
|
+
// code → exit code (N-8: メッセージにファイル本文を含めない)
|
|
480
|
+
return error.code === 'not_found' ? 2 : error.code === 'conflict' ? 3 : 1;
|
|
481
|
+
}
|
|
482
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
483
|
+
return 1;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async function main() {
|
|
487
|
+
try {
|
|
488
|
+
const rawArgs = process.argv.slice(2);
|
|
489
|
+
// task サブコマンドは独自パーサ (init/serve のフラグ解析とは別系統)
|
|
490
|
+
if (rawArgs[0] === 'task') {
|
|
491
|
+
process.exitCode = await runTaskCli(rawArgs.slice(1));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// overview サブコマンド (波 VII): 位置引数 (dir...) + --port のみ。不明な引数は fail-fast (R12-2)
|
|
495
|
+
if (rawArgs[0] === 'overview') {
|
|
496
|
+
const { dirs, port } = parseOverviewArgs(rawArgs.slice(1));
|
|
497
|
+
const running = await runOverview({ dirs, port });
|
|
498
|
+
const shutdown = () => {
|
|
499
|
+
void running.close().then(() => process.exit(0));
|
|
500
|
+
};
|
|
501
|
+
process.on('SIGINT', shutdown);
|
|
502
|
+
process.on('SIGTERM', shutdown);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const { command, port, memberId, yes } = parseArgs(rawArgs);
|
|
506
|
+
if (command === 'init') {
|
|
507
|
+
await runInit({
|
|
508
|
+
cwd: process.cwd(),
|
|
509
|
+
memberId,
|
|
510
|
+
confirm: yes ? async () => true : promptConfirm,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
else if (command === 'serve') {
|
|
514
|
+
const running = await runServe({ cwd: process.cwd(), port });
|
|
515
|
+
const shutdown = () => {
|
|
516
|
+
void running.close().then(() => process.exit(0));
|
|
517
|
+
};
|
|
518
|
+
process.on('SIGINT', shutdown);
|
|
519
|
+
process.on('SIGTERM', shutdown);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
console.error(`不明なコマンド: ${command}\n` +
|
|
523
|
+
'使い方: aizuchi [init|serve|task] [--port <番号>] [--member <ID>] [--yes]\n' +
|
|
524
|
+
` aizuchi overview <dir...> [--port <番号>] (複数 .kanban の read-only 集約・既定ポート ${DEFAULT_OVERVIEW_PORT})`);
|
|
525
|
+
process.exitCode = 1;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
530
|
+
process.exitCode = 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** ESM entry gate (LEARN#14): テストが import した時は main を実行しない */
|
|
534
|
+
function isEntry() {
|
|
535
|
+
if (!process.argv[1])
|
|
536
|
+
return false;
|
|
537
|
+
try {
|
|
538
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (isEntry()) {
|
|
545
|
+
void main();
|
|
546
|
+
}
|