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,817 @@
|
|
|
1
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
5
|
+
import { HTTPException } from 'hono/http-exception';
|
|
6
|
+
import { streamSSE } from 'hono/streaming';
|
|
7
|
+
import { commentInputSchema, configSchema, fileSaveInputSchema, renameInputSchema, findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, rejectInputSchema, saveInputSchema, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../shared/schema.js';
|
|
8
|
+
import { loadArchive, loadBoard, loadConfig, loadTaskView } from './board.js';
|
|
9
|
+
import { createEventBus } from './events.js';
|
|
10
|
+
import { allocateNextId, createSerialQueue } from './lib/id.js';
|
|
11
|
+
import { assertDirInsideKanban, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
12
|
+
import { buildDocsTree, readDoc } from './lib/docs.js';
|
|
13
|
+
import { findCommitsForTask } from './lib/git.js';
|
|
14
|
+
import { buildCatalog, buildFileTree, fileMtime, readConfigFile, saveConfigFile } from './lib/files.js';
|
|
15
|
+
import { applyPinpointPatch, renameTaskId, replaceDependsOnId, verifyPinpointResult, verifyRename, } from './lib/pinpoint.js';
|
|
16
|
+
import { appendCommentLine, appendWorkLogLine, replaceBody } from './lib/proposal.js';
|
|
17
|
+
import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './lib/task-file.js';
|
|
18
|
+
import { archiveMonth, nowIso, workLogTimestamp } from './lib/time.js';
|
|
19
|
+
/**
|
|
20
|
+
* Hono アプリ (REST API + SSE)。要件 §6.7 のうち Phase 1 スコープの
|
|
21
|
+
* board / tasks / events のみを実装する (アーカイブ・承認・却下 API は Phase 2)。
|
|
22
|
+
*
|
|
23
|
+
* ログ方針 (N-8): タスク本文・設定ファイル内容をレスポンス以外 (サーバーログ) に出さない。
|
|
24
|
+
* エラーメッセージにはパスと操作種別までを含める。
|
|
25
|
+
*/
|
|
26
|
+
/** ダッシュボード起票時の既定ステータス (人間の起票は free 扱い。提案ワークフローは Phase 2) */
|
|
27
|
+
const NEW_TASK_STATUS = 'todo';
|
|
28
|
+
/** Host ヘッダ (`<hostname>[:port]`) が loopback を指すかを判定する (DNS rebinding 緩和)。overview モードと共有 */
|
|
29
|
+
export function isLoopbackHost(host) {
|
|
30
|
+
// IPv6 リテラルは "[::1]:port"。括弧内を取り出し、無ければ末尾 ":port" を外す
|
|
31
|
+
const bracket = host.match(/^\[([^\]]+)\]/);
|
|
32
|
+
const hostname = bracket ? bracket[1] : host.replace(/:\d+$/, '');
|
|
33
|
+
if (hostname === 'localhost' || hostname === '::1')
|
|
34
|
+
return true;
|
|
35
|
+
// 127.0.0.0/8 は全て loopback
|
|
36
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* board / overview の両アプリに共通のセキュリティミドルウェアを適用する (R14-5)。
|
|
40
|
+
* (1) 全レスポンスに CSP / X-Frame-Options / nosniff (多層防御・clickjacking 対策)
|
|
41
|
+
* (2) /api/* に Host ヘッダ検証 (DNS rebinding 緩和)
|
|
42
|
+
* (3) /api/* の状態変更系に CSRF 対策 (Content-Type 厳格 415 + Sec-Fetch-Site 403)
|
|
43
|
+
* (4) /api/* に bodyLimit (巨大ボディ 413)
|
|
44
|
+
* overview は現状 GET のみだが、将来 API が増えた時の非対称 (CSRF 欠落等) を防ぐため board と同一にする。
|
|
45
|
+
* N=3 (board CSP/Host/CSRF + overview CSP/Host) で共通化 (LEARN#8b)。
|
|
46
|
+
*/
|
|
47
|
+
export function applySecurityMiddleware(app) {
|
|
48
|
+
// セキュリティヘッダ (R4-2): 全レスポンスに付与する多層防御。
|
|
49
|
+
// ローカル SPA なので self 由来のみ許可。Vite ビルドは外部 JS バンドル + inline style のため
|
|
50
|
+
// style-src のみ unsafe-inline を許す。clickjacking 対策に X-Frame-Options: DENY も付ける。
|
|
51
|
+
app.use('*', async (c, next) => {
|
|
52
|
+
await next();
|
|
53
|
+
c.res.headers.set('content-security-policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'");
|
|
54
|
+
c.res.headers.set('x-frame-options', 'DENY');
|
|
55
|
+
c.res.headers.set('x-content-type-options', 'nosniff');
|
|
56
|
+
});
|
|
57
|
+
// Host ヘッダ検証 (N-1 補完): 127.0.0.1 バインドはネット越しを防ぐが、ブラウザ起点の
|
|
58
|
+
// DNS rebinding は防げない。Host が loopback (localhost / 127.x / ::1) / 任意ポート 以外なら 403。
|
|
59
|
+
// 攻撃 (DNS rebinding) はブラウザが必ず付ける Host が攻撃者ドメインになる点を突くため、
|
|
60
|
+
// Host 不在 (HTTP/1.1 では必須。CLI ツールや app.request テストは付けないことがある) は許可する。
|
|
61
|
+
app.use('/api/*', async (c, next) => {
|
|
62
|
+
const host = c.req.header('host');
|
|
63
|
+
if (host !== undefined && host !== '' && !isLoopbackHost(host)) {
|
|
64
|
+
return c.json({ error: '許可されていない Host ヘッダ' }, 403);
|
|
65
|
+
}
|
|
66
|
+
await next();
|
|
67
|
+
});
|
|
68
|
+
// CSRF 対策 (R4-1): 状態変更系 (POST/PUT/PATCH) で simple-request CSRF を防ぐ。
|
|
69
|
+
// (1) Content-Type を application/json 厳密必須化 → text/plain 等の simple request が成立せず
|
|
70
|
+
// preflight 強制 → 悪意ページは CORS ヘッダ無しで失敗する
|
|
71
|
+
// (2) Sec-Fetch-Site (ブラウザが必ず付与) が same-origin / none 以外なら 403
|
|
72
|
+
// GET は読出専用 (CORS ヘッダ無しでクロスオリジン読出不可) なので対象外。
|
|
73
|
+
app.use('/api/*', async (c, next) => {
|
|
74
|
+
const method = c.req.method;
|
|
75
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
76
|
+
const contentType = c.req.header('content-type') ?? '';
|
|
77
|
+
// "application/json" / "application/json; charset=utf-8" を許可。それ以外は 415
|
|
78
|
+
const mediaType = contentType.split(';')[0].trim().toLowerCase();
|
|
79
|
+
if (mediaType !== 'application/json') {
|
|
80
|
+
return c.json({ error: 'Content-Type は application/json が必須です' }, 415);
|
|
81
|
+
}
|
|
82
|
+
// Sec-Fetch-Site があり same-origin / none 以外 (cross-site / same-site) なら拒否
|
|
83
|
+
const fetchSite = c.req.header('sec-fetch-site');
|
|
84
|
+
if (fetchSite !== undefined && fetchSite !== 'same-origin' && fetchSite !== 'none') {
|
|
85
|
+
return c.json({ error: 'クロスオリジンからの操作は許可されていません' }, 403);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await next();
|
|
89
|
+
});
|
|
90
|
+
// 書込系の巨大ボディを 413 で拒否する (ローカル前提だが防御を一段足す)
|
|
91
|
+
app.use('/api/*', bodyLimit({ maxSize: 1024 * 1024 }));
|
|
92
|
+
}
|
|
93
|
+
export function createApp(options) {
|
|
94
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
95
|
+
const kanbanDir = path.join(projectRoot, '.kanban');
|
|
96
|
+
const tasksDir = path.join(kanbanDir, 'tasks');
|
|
97
|
+
const bus = options.bus ?? createEventBus();
|
|
98
|
+
const enqueue = createSerialQueue();
|
|
99
|
+
const app = new Hono();
|
|
100
|
+
// R17-D: .kanban の realpath 信頼境界を確立する。各ルートの .kanban 直 read はこれを取得して
|
|
101
|
+
// statKanbanFile / readKanbanFile に渡し、中間 dir symlink (.kanban/tasks 等) 経由の外部漏洩を防ぐ。
|
|
102
|
+
// .kanban 自体が symlink / 不在なら throw する (呼び出し側で 404 / 既定値に倒す)。
|
|
103
|
+
const getKanbanReal = () => resolveKanbanReal(projectRoot);
|
|
104
|
+
/**
|
|
105
|
+
* タスクファイル本文 (content) を archive/<YYYY-MM>/<id>.md へ排他移動し、元 (tasks/) を削除する。
|
|
106
|
+
* 「archive 排他書込 → 成功後に元 rm」順序でタスク消失を防ぐ (reject / archive で共用)。
|
|
107
|
+
* archive に同 ID があれば FileExistsError を投げる (呼び出し側で 409)。archive の相対パスを返す。
|
|
108
|
+
*
|
|
109
|
+
* R17-F (M2): mkdir 前に archive の realpath containment を検査する。旧実装は `fs.mkdir(recursive)` が
|
|
110
|
+
* lexical な archive symlink を辿り root 外に空ディレクトリを作れた (ファイル書込は safeWriteFile が遮断済み
|
|
111
|
+
* だが空 dir は残る)。.kanban が symlink / archive 自体が外部 symlink なら PathValidationError で中止する。
|
|
112
|
+
*/
|
|
113
|
+
const moveToArchive = async (id, content) => {
|
|
114
|
+
const kanbanReal = await getKanbanReal(); // .kanban 自体が symlink なら throw
|
|
115
|
+
const archiveDir = path.join(kanbanReal, 'archive');
|
|
116
|
+
// archive 自体が外部 dir への symlink でないことを realpath で検査 (mkdir で symlink 先に空 dir を作らせない)。
|
|
117
|
+
// archive 不在は許容 (mkdir recursive が正規に作る) ため、存在する場合のみ containment を確認する。
|
|
118
|
+
if (existsSync(archiveDir)) {
|
|
119
|
+
await assertDirInsideKanban(archiveDir, kanbanReal);
|
|
120
|
+
}
|
|
121
|
+
const monthDir = path.join(archiveDir, archiveMonth());
|
|
122
|
+
await fs.mkdir(monthDir, { recursive: true });
|
|
123
|
+
const archivePath = path.join(monthDir, `${id}.md`);
|
|
124
|
+
await safeWriteFile(archivePath, content, [kanbanDir], { exclusive: true });
|
|
125
|
+
await fs.rm(path.join(tasksDir, `${id}.md`), { force: true });
|
|
126
|
+
return path.relative(projectRoot, archivePath);
|
|
127
|
+
};
|
|
128
|
+
// セキュリティミドルウェア (CSP / Host / CSRF / bodyLimit) は overview と共通 (R14-5)
|
|
129
|
+
applySecurityMiddleware(app);
|
|
130
|
+
app.onError((error, c) => {
|
|
131
|
+
// hono の HTTPException (bodyLimit の 413 等) は自前のレスポンスを尊重する
|
|
132
|
+
// (一律 500 に丸めると 413 等の意味が失われる)
|
|
133
|
+
if (error instanceof HTTPException) {
|
|
134
|
+
return error.getResponse();
|
|
135
|
+
}
|
|
136
|
+
if (error instanceof PathValidationError) {
|
|
137
|
+
return c.json({ error: error.message }, 400);
|
|
138
|
+
}
|
|
139
|
+
// 原因調査用に種別のみログへ出す (N-8: タスク本文・設定内容・エラー詳細は出さない)
|
|
140
|
+
console.error('[aizuchi]', c.req.method, c.req.path, error.name);
|
|
141
|
+
return c.json({ error: 'サーバー内部エラー' }, 500);
|
|
142
|
+
});
|
|
143
|
+
// ---------------------------------------------------------------------
|
|
144
|
+
// GET /api/mode — SPA の起動時分岐用 (波 VII: serve=board / overview=overview)
|
|
145
|
+
// ---------------------------------------------------------------------
|
|
146
|
+
app.get('/api/mode', (c) => {
|
|
147
|
+
return c.json({ mode: 'board' });
|
|
148
|
+
});
|
|
149
|
+
// ---------------------------------------------------------------------
|
|
150
|
+
// GET /api/board — 全タスク + config + 警告
|
|
151
|
+
// ---------------------------------------------------------------------
|
|
152
|
+
app.get('/api/board', async (c) => {
|
|
153
|
+
return c.json(await loadBoard(projectRoot));
|
|
154
|
+
});
|
|
155
|
+
// ---------------------------------------------------------------------
|
|
156
|
+
// GET /api/archive — アーカイブ済みタスクの本文 (日次ダイジェストの集計用・読出専用)
|
|
157
|
+
// ---------------------------------------------------------------------
|
|
158
|
+
app.get('/api/archive', async (c) => {
|
|
159
|
+
return c.json({ tasks: await loadArchive(projectRoot) });
|
|
160
|
+
});
|
|
161
|
+
// ---------------------------------------------------------------------
|
|
162
|
+
// POST /api/tasks — 起票 (採番はプロセス内キューで直列化)
|
|
163
|
+
// ---------------------------------------------------------------------
|
|
164
|
+
app.post('/api/tasks', async (c) => {
|
|
165
|
+
const body = await c.req.json().catch(() => null);
|
|
166
|
+
const input = newTaskInputSchema.safeParse(body);
|
|
167
|
+
if (!input.success) {
|
|
168
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
169
|
+
}
|
|
170
|
+
const { config } = await loadConfig(projectRoot);
|
|
171
|
+
const violations = findMemberViolations({ created_by: input.data.created_by, assignee: input.data.assignee ?? null }, config);
|
|
172
|
+
if (violations.length > 0) {
|
|
173
|
+
return c.json({ error: violations.join(' / ') }, 400);
|
|
174
|
+
}
|
|
175
|
+
// 走査 → 採番 → 書込 をアトミックに直列化する (同時起票の ID 重複防止)。
|
|
176
|
+
// 書込は排他作成 (exclusive) にし、採番後の窓で AI が同 ID を直書きしていた場合は
|
|
177
|
+
// 上書きせず再採番 retry する (N-3: POST が外部データを silent 上書きするのを防ぐ)
|
|
178
|
+
const view = await enqueue(async () => {
|
|
179
|
+
const MAX_RETRY = 5;
|
|
180
|
+
for (let attempt = 0;; attempt++) {
|
|
181
|
+
const id = await allocateNextId(kanbanDir, config.idPrefix);
|
|
182
|
+
const now = nowIso();
|
|
183
|
+
const frontmatter = taskFrontmatterSchema.parse({
|
|
184
|
+
id,
|
|
185
|
+
title: input.data.title,
|
|
186
|
+
status: NEW_TASK_STATUS,
|
|
187
|
+
assignee: input.data.assignee ?? null,
|
|
188
|
+
created_by: input.data.created_by,
|
|
189
|
+
priority: input.data.priority ?? 'medium',
|
|
190
|
+
labels: input.data.labels ?? [],
|
|
191
|
+
blocked_type: null,
|
|
192
|
+
blocked_reason: null,
|
|
193
|
+
created: now,
|
|
194
|
+
updated: now,
|
|
195
|
+
});
|
|
196
|
+
// 本文: テンプレート由来の body があればそれを、無ければ既定本文テンプレートを使う (波 VI)。
|
|
197
|
+
// R11-6: serializeTaskFile は `---\n` 直後に body を置くため、先頭が改行でない body は
|
|
198
|
+
// frontmatter に密着する。NEW_TASK_BODY (先頭改行あり) と対称にするため \n を前置する
|
|
199
|
+
const rawBody = input.data.body;
|
|
200
|
+
const taskBody = rawBody !== undefined && rawBody.length > 0
|
|
201
|
+
? rawBody.startsWith('\n')
|
|
202
|
+
? rawBody
|
|
203
|
+
: `\n${rawBody}`
|
|
204
|
+
: NEW_TASK_BODY;
|
|
205
|
+
const filePath = path.join(tasksDir, `${id}.md`);
|
|
206
|
+
try {
|
|
207
|
+
await safeWriteFile(filePath, serializeTaskFile(frontmatter, taskBody), [kanbanDir], {
|
|
208
|
+
exclusive: true,
|
|
209
|
+
});
|
|
210
|
+
return loadTaskView(projectRoot, filePath);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// 採番後に AI が同 ID を作っていた場合は再採番してやり直す
|
|
214
|
+
if (error instanceof FileExistsError && attempt < MAX_RETRY)
|
|
215
|
+
continue;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
return c.json(view, 201);
|
|
221
|
+
});
|
|
222
|
+
// ---------------------------------------------------------------------
|
|
223
|
+
// PATCH /api/tasks/:id — D&D のステータス変更 (行置換のみ・本文保全)
|
|
224
|
+
// ---------------------------------------------------------------------
|
|
225
|
+
app.patch('/api/tasks/:id', async (c) => {
|
|
226
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
227
|
+
if (!idResult.success) {
|
|
228
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
229
|
+
}
|
|
230
|
+
const input = moveInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
231
|
+
if (!input.success) {
|
|
232
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
233
|
+
}
|
|
234
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
235
|
+
// 読込 → 行置換 → 書込 をキューで直列化する (プロセス内の write-write 競合の窓を閉じる)
|
|
236
|
+
return enqueue(async () => {
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
// R17-D: realpath containment + symlink 非追従で読む。symlink タスク / 中間 dir symlink は 404 に倒す
|
|
240
|
+
raw = await readKanbanFile(filePath, await getKanbanReal());
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
244
|
+
}
|
|
245
|
+
// 破損ファイルは移動先カラムに依らず 409 + 理由 (内容抜粋なし) を返す
|
|
246
|
+
let current;
|
|
247
|
+
try {
|
|
248
|
+
current = parseTaskFile(raw);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
252
|
+
return c.json({ error: `更新できない (${message})` }, 409);
|
|
253
|
+
}
|
|
254
|
+
const patch = { status: input.data.status, updated: nowIso() };
|
|
255
|
+
if (input.data.status === 'pending') {
|
|
256
|
+
// pending 行きはダイアログで取得した blocked_* を同梱する (技術決定⑥)
|
|
257
|
+
patch.blocked_type = input.data.blocked_type ?? null;
|
|
258
|
+
patch.blocked_reason = input.data.blocked_reason ?? null;
|
|
259
|
+
}
|
|
260
|
+
else if (current.data.blocked_type != null || current.data.blocked_reason != null) {
|
|
261
|
+
// pending 以外への移動では blocked_* が残っていれば null へ戻す (経緯は作業ログに残る前提)
|
|
262
|
+
patch.blocked_type = null;
|
|
263
|
+
patch.blocked_reason = null;
|
|
264
|
+
}
|
|
265
|
+
let patched;
|
|
266
|
+
let patchedParsed;
|
|
267
|
+
try {
|
|
268
|
+
patched = applyPinpointPatch(raw, patch);
|
|
269
|
+
patchedParsed = parseTaskFile(patched);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
const message = error instanceof TaskFileParseError ? error.message : error instanceof Error ? error.message : '更新に失敗';
|
|
273
|
+
return c.json({ error: `更新できない (${message})` }, 409);
|
|
274
|
+
}
|
|
275
|
+
// verify-after-patch ガード: 行置換が「意図した frontmatter フィールドだけを意図どおりに変え、
|
|
276
|
+
// 本文は不変」かを再 parse で検証する。block scalar / 特殊表記で取りこぼし・巻き添え・本文破壊が
|
|
277
|
+
// 出た場合は壊れた内容を書き込まず 409 で拒否する (PM Round 1 #2/#3/#6 + Round 2 R2-2)
|
|
278
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
279
|
+
if (drift !== null) {
|
|
280
|
+
return c.json({ error: `更新できない (${drift})` }, 409);
|
|
281
|
+
}
|
|
282
|
+
// 書換後の frontmatter がスキーマを満たすか検証する (pending なのに blocked 無し等は 400)
|
|
283
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
284
|
+
if (!check.success) {
|
|
285
|
+
return c.json({ error: 'ステータス変更後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
286
|
+
}
|
|
287
|
+
await safeWriteFile(filePath, patched, [kanbanDir]);
|
|
288
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
// ---------------------------------------------------------------------
|
|
292
|
+
// PUT /api/tasks/:id — 本文を含む全体更新 (mtime 厳密比較で競合検知)
|
|
293
|
+
// ---------------------------------------------------------------------
|
|
294
|
+
app.put('/api/tasks/:id', async (c) => {
|
|
295
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
296
|
+
if (!idResult.success) {
|
|
297
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
298
|
+
}
|
|
299
|
+
const input = saveInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
300
|
+
if (!input.success) {
|
|
301
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
302
|
+
}
|
|
303
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
304
|
+
// mtime 検査 → 検証 → 書込 をキューで直列化する (プロセス内の write-write 競合の窓を閉じる)
|
|
305
|
+
return enqueue(async () => {
|
|
306
|
+
let stat;
|
|
307
|
+
let serverContent;
|
|
308
|
+
try {
|
|
309
|
+
// R17-D: realpath containment + symlink 非追従で stat/read する。symlink task / 中間 dir symlink は
|
|
310
|
+
// 404 に倒し、409 の serverContent に外部ファイル全文が載るのを防ぐ (read 面 containment の最重要箇所)
|
|
311
|
+
const kanbanReal = await getKanbanReal();
|
|
312
|
+
stat = await statKanbanFile(filePath, kanbanReal);
|
|
313
|
+
serverContent = await readKanbanFile(filePath, kanbanReal);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
317
|
+
}
|
|
318
|
+
// mtimeMs は数値のまま厳密比較する (N-3)
|
|
319
|
+
if (stat.mtimeMs !== input.data.baseMtimeMs) {
|
|
320
|
+
return c.json({ error: '外部で変更されています', serverContent, serverMtimeMs: stat.mtimeMs }, 409);
|
|
321
|
+
}
|
|
322
|
+
// 保存時のスキーマ検証 (F-2-2)
|
|
323
|
+
let parsed;
|
|
324
|
+
try {
|
|
325
|
+
parsed = parseTaskFile(input.data.content);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
const message = error instanceof TaskFileParseError ? error.message : '本文を解析できない';
|
|
329
|
+
return c.json({ error: message }, 400);
|
|
330
|
+
}
|
|
331
|
+
const check = taskFrontmatterSchema.safeParse(parsed.data);
|
|
332
|
+
if (!check.success) {
|
|
333
|
+
return c.json({ error: 'frontmatter が不正', issues: summarizeZodError(check.error) }, 400);
|
|
334
|
+
}
|
|
335
|
+
if (check.data.id !== idResult.data) {
|
|
336
|
+
return c.json({ error: `frontmatter の id (${check.data.id}) は変更できない` }, 400);
|
|
337
|
+
}
|
|
338
|
+
await safeWriteFile(filePath, input.data.content, [kanbanDir]);
|
|
339
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
// ---------------------------------------------------------------------
|
|
343
|
+
// POST /api/tasks/:id/approve — 提案の承認 (proposed → todo) (F-3-3)
|
|
344
|
+
// ---------------------------------------------------------------------
|
|
345
|
+
app.post('/api/tasks/:id/approve', async (c) => {
|
|
346
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
347
|
+
if (!idResult.success) {
|
|
348
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
349
|
+
}
|
|
350
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
351
|
+
return enqueue(async () => {
|
|
352
|
+
let raw;
|
|
353
|
+
try {
|
|
354
|
+
raw = await readKanbanFile(filePath, await getKanbanReal()); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
358
|
+
}
|
|
359
|
+
let current;
|
|
360
|
+
try {
|
|
361
|
+
current = parseTaskFile(raw);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
365
|
+
return c.json({ error: `承認できない (${message})` }, 409);
|
|
366
|
+
}
|
|
367
|
+
// 承認は proposed のタスクにのみ許す (提案ワークフローの状態遷移)
|
|
368
|
+
if (current.data.status !== 'proposed') {
|
|
369
|
+
return c.json({ error: 'proposed のタスクのみ承認できます' }, 409);
|
|
370
|
+
}
|
|
371
|
+
// status を todo に行置換 (本文・他フィールドは保全)。verify-after-patch ガードを通す
|
|
372
|
+
const patch = { status: 'todo', updated: nowIso() };
|
|
373
|
+
let patched;
|
|
374
|
+
let patchedParsed;
|
|
375
|
+
try {
|
|
376
|
+
patched = applyPinpointPatch(raw, patch);
|
|
377
|
+
patchedParsed = parseTaskFile(patched);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
const message = error instanceof Error ? error.message : '承認に失敗';
|
|
381
|
+
return c.json({ error: `承認できない (${message})` }, 409);
|
|
382
|
+
}
|
|
383
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
384
|
+
if (drift !== null) {
|
|
385
|
+
return c.json({ error: `承認できない (${drift})` }, 409);
|
|
386
|
+
}
|
|
387
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
388
|
+
if (!check.success) {
|
|
389
|
+
return c.json({ error: '承認後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
390
|
+
}
|
|
391
|
+
// 承認痕跡を作業ログに残す (F-3-4 の誤検知防止 R6-1)。pinpoint の frontmatter 検証を
|
|
392
|
+
// 通した後に body だけ更新する。これで「proposed→approve した正規タスク」は承認痕跡を持ち、
|
|
393
|
+
// モード違反検知 (board.ts) の対象から外れる
|
|
394
|
+
const logLine = `${workLogTimestamp()} [approve] 承認: proposed → todo`;
|
|
395
|
+
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
396
|
+
await safeWriteFile(filePath, finalRaw, [kanbanDir]);
|
|
397
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
// ---------------------------------------------------------------------
|
|
401
|
+
// POST /api/tasks/:id/reject — 提案の却下 (作業ログに理由 → archive へ移動) (F-3-3)
|
|
402
|
+
// ---------------------------------------------------------------------
|
|
403
|
+
app.post('/api/tasks/:id/reject', async (c) => {
|
|
404
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
405
|
+
if (!idResult.success) {
|
|
406
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
407
|
+
}
|
|
408
|
+
const input = rejectInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
409
|
+
if (!input.success) {
|
|
410
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
411
|
+
}
|
|
412
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
413
|
+
return enqueue(async () => {
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = await readKanbanFile(filePath, await getKanbanReal()); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
420
|
+
}
|
|
421
|
+
let current;
|
|
422
|
+
try {
|
|
423
|
+
current = parseTaskFile(raw);
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
427
|
+
return c.json({ error: `却下できない (${message})` }, 409);
|
|
428
|
+
}
|
|
429
|
+
if (current.data.status !== 'proposed') {
|
|
430
|
+
return c.json({ error: 'proposed のタスクのみ却下できます' }, 409);
|
|
431
|
+
}
|
|
432
|
+
// 作業ログに却下理由を 1 行追記する (本文の更新なので raw 全体を再構成する)。
|
|
433
|
+
// frontmatter ブロックは行置換せずそのまま残し、本文 (body) だけに追記する
|
|
434
|
+
const logLine = `${workLogTimestamp()} [reject] 却下: ${input.data.reason}`;
|
|
435
|
+
const newBody = appendWorkLogLine(current.body, logLine);
|
|
436
|
+
// raw の frontmatter 部分 (先頭〜 2 つ目の ---) を保持し、body だけ差し替える
|
|
437
|
+
const rebuilt = replaceBody(raw, newBody);
|
|
438
|
+
try {
|
|
439
|
+
const archived = await moveToArchive(idResult.data, rebuilt);
|
|
440
|
+
return c.json({ id: idResult.data, archived });
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error instanceof FileExistsError) {
|
|
444
|
+
return c.json({ error: `archive に同じ ID が既に存在します: ${idResult.data}` }, 409);
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
// ---------------------------------------------------------------------
|
|
451
|
+
// POST /api/tasks/:id/archive — done タスクを archive へ移動 (F-1-10)
|
|
452
|
+
// ---------------------------------------------------------------------
|
|
453
|
+
app.post('/api/tasks/:id/archive', async (c) => {
|
|
454
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
455
|
+
if (!idResult.success) {
|
|
456
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
457
|
+
}
|
|
458
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
459
|
+
return enqueue(async () => {
|
|
460
|
+
let raw;
|
|
461
|
+
try {
|
|
462
|
+
raw = await readKanbanFile(filePath, await getKanbanReal()); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
466
|
+
}
|
|
467
|
+
let current;
|
|
468
|
+
try {
|
|
469
|
+
current = parseTaskFile(raw);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
473
|
+
return c.json({ error: `アーカイブできない (${message})` }, 409);
|
|
474
|
+
}
|
|
475
|
+
// done のみアーカイブ可 (要件 §5.4: アーカイブは完了タスクの所在移動)
|
|
476
|
+
if (current.data.status !== 'done') {
|
|
477
|
+
return c.json({ error: 'done のタスクのみアーカイブできます' }, 409);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const archived = await moveToArchive(idResult.data, raw); // 本文は変更せずそのまま移動
|
|
481
|
+
return c.json({ id: idResult.data, archived });
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error instanceof FileExistsError) {
|
|
485
|
+
return c.json({ error: `archive に同じ ID が既に存在します: ${idResult.data}` }, 409);
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
// ---------------------------------------------------------------------
|
|
492
|
+
// POST /api/tasks/:id/rename — 重複 ID を空き番号へリネーム (F-6-5)
|
|
493
|
+
// ---------------------------------------------------------------------
|
|
494
|
+
app.post('/api/tasks/:id/rename', async (c) => {
|
|
495
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
496
|
+
if (!idResult.success) {
|
|
497
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
498
|
+
}
|
|
499
|
+
// body は空オブジェクトのみ許可 (R8 Minor: 余計なキーを strict で弾く)
|
|
500
|
+
const input = renameInputSchema.safeParse(await c.req.json().catch(() => ({})));
|
|
501
|
+
if (!input.success) {
|
|
502
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
503
|
+
}
|
|
504
|
+
const oldId = idResult.data;
|
|
505
|
+
const oldPath = path.join(tasksDir, `${oldId}.md`);
|
|
506
|
+
return enqueue(async () => {
|
|
507
|
+
let raw;
|
|
508
|
+
try {
|
|
509
|
+
raw = await readKanbanFile(oldPath, await getKanbanReal()); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return c.json({ error: `タスクが見つからない: ${oldId}` }, 404);
|
|
513
|
+
}
|
|
514
|
+
// 破損ファイルはリネーム前に弾く (壊れた id を作って元削除しないように)
|
|
515
|
+
let before;
|
|
516
|
+
try {
|
|
517
|
+
before = parseTaskFile(raw);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
521
|
+
return c.json({ error: `リネームできない (${message})` }, 409);
|
|
522
|
+
}
|
|
523
|
+
const { config } = await loadConfig(projectRoot);
|
|
524
|
+
// 空き番号を採番し、id 行を書き換える
|
|
525
|
+
const newId = await allocateNextId(kanbanDir, config.idPrefix);
|
|
526
|
+
const renamed = renameTaskId(raw, newId);
|
|
527
|
+
// verify-after-rename ガード (R8-4): 新 id がファイル名と一致 & 他フィールド/body 不変かを
|
|
528
|
+
// 書込・削除の前に検証する。drift なら元ファイルを残し 409 (壊れた id 表記で消失させない)
|
|
529
|
+
let after;
|
|
530
|
+
try {
|
|
531
|
+
after = parseTaskFile(renamed);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
const message = error instanceof TaskFileParseError ? error.message : 'リネーム結果を解析できない';
|
|
535
|
+
return c.json({ error: `リネームできない (${message})` }, 409);
|
|
536
|
+
}
|
|
537
|
+
const drift = verifyRename(before, after, newId);
|
|
538
|
+
if (drift !== null) {
|
|
539
|
+
return c.json({ error: `リネームできない (${drift})` }, 409);
|
|
540
|
+
}
|
|
541
|
+
const newPath = path.join(tasksDir, `${newId}.md`);
|
|
542
|
+
try {
|
|
543
|
+
await safeWriteFile(newPath, renamed, [kanbanDir], { exclusive: true });
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
if (error instanceof FileExistsError) {
|
|
547
|
+
return c.json({ error: `採番した ID が既に存在します: ${newId}` }, 409);
|
|
548
|
+
}
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
await fs.rm(oldPath, { force: true });
|
|
552
|
+
// 他タスクの depends_on に含まれる旧 ID を新 ID へ更新する (F-6-5 連鎖更新)
|
|
553
|
+
let names = [];
|
|
554
|
+
const chainKanbanReal = await getKanbanReal(); // ループ前に realpath 信頼境界を一度確立
|
|
555
|
+
try {
|
|
556
|
+
names = await fs.readdir(tasksDir);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
names = [];
|
|
560
|
+
}
|
|
561
|
+
for (const name of names) {
|
|
562
|
+
if (!name.endsWith('.md') || name === `${newId}.md`)
|
|
563
|
+
continue;
|
|
564
|
+
const p = path.join(tasksDir, name);
|
|
565
|
+
let content;
|
|
566
|
+
try {
|
|
567
|
+
content = await readKanbanFile(p, chainKanbanReal); // R17-D: containment + symlink 非追従 (symlink は連鎖更新対象外)
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const updated = replaceDependsOnId(content, oldId, newId);
|
|
573
|
+
if (updated !== content) {
|
|
574
|
+
await safeWriteFile(p, updated, [kanbanDir]);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return c.json({ oldId, newId });
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
// ---------------------------------------------------------------------
|
|
581
|
+
// GET /api/config — 生 config JSON を返す (設定画面 F-5 の編集元)
|
|
582
|
+
// ---------------------------------------------------------------------
|
|
583
|
+
app.get('/api/config', async (c) => {
|
|
584
|
+
const configPath = path.join(kanbanDir, 'config.json');
|
|
585
|
+
try {
|
|
586
|
+
// mtime → read の順 (loadTaskView と同じ理由: 不整合を誤 409 側に倒す)。
|
|
587
|
+
// mtime はヘッダ x-config-mtime-ms で返し、PUT の競合検知 (N-3) に使う。
|
|
588
|
+
// 生テキストはそのまま返す (ユーザーの独自フィールド・整形を保つ) ため body には載せない
|
|
589
|
+
// R17-D: realpath containment + symlink 非追従で stat/read する。symlinked config.json /
|
|
590
|
+
// .kanban の中間 dir symlink による外部全文漏洩を防ぐ (symlink/不在なら catch で既定値へ)
|
|
591
|
+
const kanbanReal = await getKanbanReal();
|
|
592
|
+
const stat = await statKanbanFile(configPath, kanbanReal);
|
|
593
|
+
const raw = await readKanbanFile(configPath, kanbanReal);
|
|
594
|
+
return c.body(raw, 200, {
|
|
595
|
+
'content-type': 'application/json; charset=utf-8',
|
|
596
|
+
'x-config-mtime-ms': String(stat.mtimeMs),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// config が無い / symlink なら既定値を返す (N-6)。ファイルが無いので mtime は付けない (新規保存は素通り)
|
|
601
|
+
const { config } = await loadConfig(projectRoot);
|
|
602
|
+
return c.json(config);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
// ---------------------------------------------------------------------
|
|
606
|
+
// PUT /api/config — config 保存。schema は「検証のみ」で生 JSON を書く (R1 方針)
|
|
607
|
+
// ---------------------------------------------------------------------
|
|
608
|
+
app.put('/api/config', async (c) => {
|
|
609
|
+
const body = await c.req.json().catch(() => undefined);
|
|
610
|
+
if (body === undefined) {
|
|
611
|
+
return c.json({ error: 'JSON として読めない本文' }, 400);
|
|
612
|
+
}
|
|
613
|
+
// 空 {} / 配列 / 非オブジェクトを拒否する (R6 Minor: 空 config で全消去できないように)。
|
|
614
|
+
// configSchema は全フィールド default 持ちのため {} でも parse は通るが、意味ある保存ではない
|
|
615
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
616
|
+
return c.json({ error: 'config が空または不正です (少なくとも 1 つのフィールドが必要)' }, 400);
|
|
617
|
+
}
|
|
618
|
+
// 検証のみ: schema を満たすか確認するが、strip 後の値ではなく生 JSON を書き戻す
|
|
619
|
+
// (configSchema は unknown キーを strip するため、書き戻すとユーザーの独自フィールドが消える)
|
|
620
|
+
const parsed = configSchema.safeParse(body);
|
|
621
|
+
if (!parsed.success) {
|
|
622
|
+
return c.json({ error: 'config が不正', issues: summarizeZodError(parsed.error) }, 400);
|
|
623
|
+
}
|
|
624
|
+
const configPath = path.join(kanbanDir, 'config.json');
|
|
625
|
+
// baseMtimeMs (ヘッダ x-base-mtime-ms) があれば mtime 競合検知 (N-3、PUT /api/tasks と同方式)。
|
|
626
|
+
// 空文字 / 未送信のみ skip し競合検知なしで保存する (後方互換: 旧クライアント・新規 config 作成)。
|
|
627
|
+
// R10-5: 非空かつ非数値 (例 "abc") は fail-open で握りつぶさず 400 (PUT /api/tasks が非数値を弾くのと対称)
|
|
628
|
+
const baseHeader = c.req.header('x-base-mtime-ms');
|
|
629
|
+
let baseMtimeMs = null;
|
|
630
|
+
if (baseHeader !== undefined && baseHeader !== '') {
|
|
631
|
+
const n = Number(baseHeader);
|
|
632
|
+
if (!Number.isFinite(n)) {
|
|
633
|
+
return c.json({ error: 'x-base-mtime-ms ヘッダは数値が必要です' }, 400);
|
|
634
|
+
}
|
|
635
|
+
baseMtimeMs = n;
|
|
636
|
+
}
|
|
637
|
+
// mtime 検査 → 書込 をキューで直列化する (プロセス内 write-write 競合の窓を閉じる)
|
|
638
|
+
return enqueue(async () => {
|
|
639
|
+
if (baseMtimeMs !== null) {
|
|
640
|
+
let stat;
|
|
641
|
+
let serverContent;
|
|
642
|
+
try {
|
|
643
|
+
// R17-D: realpath containment + symlink 非追従で stat/read する。symlinked config.json /
|
|
644
|
+
// 中間 dir symlink の外部全文が 409 の serverContent に載るのを防ぐ (symlink/不在は「競合なし」へ)
|
|
645
|
+
const kanbanReal = await getKanbanReal();
|
|
646
|
+
stat = await statKanbanFile(configPath, kanbanReal);
|
|
647
|
+
serverContent = await readKanbanFile(configPath, kanbanReal);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// 既存ファイルが無い / symlink (= 競合のしようがない)。素通りで新規作成へ
|
|
651
|
+
stat = null;
|
|
652
|
+
serverContent = '';
|
|
653
|
+
}
|
|
654
|
+
// mtimeMs は数値のまま厳密比較 (N-3)。drift なら 409 + サーバー側内容
|
|
655
|
+
if (stat !== null && stat.mtimeMs !== baseMtimeMs) {
|
|
656
|
+
return c.json({ error: '外部で変更されています', serverContent, serverMtimeMs: stat.mtimeMs }, 409);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
await safeWriteFile(configPath, `${JSON.stringify(body, null, 2)}\n`, [kanbanDir]);
|
|
660
|
+
return c.json({ ok: true });
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
// ---------------------------------------------------------------------
|
|
664
|
+
// AI 設定ファイル管理 F-4 (書込先が .kanban の外に広がる・N-2 の本丸)
|
|
665
|
+
// ---------------------------------------------------------------------
|
|
666
|
+
// GET /api/files?path=... — 許可リスト内ファイルの内容 (F-4-1 / F-4-6)
|
|
667
|
+
app.get('/api/files', async (c) => {
|
|
668
|
+
const relPath = c.req.query('path');
|
|
669
|
+
if (relPath === undefined || relPath === '') {
|
|
670
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
671
|
+
}
|
|
672
|
+
const { config } = await loadConfig(projectRoot);
|
|
673
|
+
try {
|
|
674
|
+
return c.json(await readConfigFile(projectRoot, config, relPath));
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
if (error instanceof PathValidationError)
|
|
678
|
+
return c.json({ error: error.message }, 403);
|
|
679
|
+
if (error.code === 'ENOENT')
|
|
680
|
+
return c.json({ error: 'ファイルが見つかりません' }, 404);
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
// PUT /api/files — 許可リスト内ファイルの保存 (.bak + mode 保全 + mtime 競合検知)
|
|
685
|
+
app.put('/api/files', async (c) => {
|
|
686
|
+
const input = fileSaveInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
687
|
+
if (!input.success) {
|
|
688
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
689
|
+
}
|
|
690
|
+
const { config } = await loadConfig(projectRoot);
|
|
691
|
+
// 直列化キューで mtime 検査 → 保存 (プロセス内 write-write 競合を閉じる)
|
|
692
|
+
return enqueue(async () => {
|
|
693
|
+
try {
|
|
694
|
+
const current = await fileMtime(projectRoot, config, input.data.path);
|
|
695
|
+
// 既存ファイルの場合のみ mtime 厳密比較 (新規作成は current=null で素通り)
|
|
696
|
+
if (current !== null && current !== input.data.baseMtimeMs) {
|
|
697
|
+
return c.json({ error: '外部で変更されています', serverMtimeMs: current }, 409);
|
|
698
|
+
}
|
|
699
|
+
const saved = await saveConfigFile(projectRoot, config, input.data.path, input.data.content);
|
|
700
|
+
return c.json(saved);
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (error instanceof PathValidationError)
|
|
704
|
+
return c.json({ error: error.message }, 403);
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
// GET /api/files/tree — browseDirs 配下のファイルツリー (F-4-2)
|
|
710
|
+
app.get('/api/files/tree', async (c) => {
|
|
711
|
+
const { config } = await loadConfig(projectRoot);
|
|
712
|
+
return c.json({ trees: await buildFileTree(projectRoot, config) });
|
|
713
|
+
});
|
|
714
|
+
// GET /api/files/catalog — skill / agent の frontmatter カタログ (F-4-4)
|
|
715
|
+
app.get('/api/files/catalog', async (c) => {
|
|
716
|
+
const { config } = await loadConfig(projectRoot);
|
|
717
|
+
return c.json({ items: await buildCatalog(projectRoot, config) });
|
|
718
|
+
});
|
|
719
|
+
// ---------------------------------------------------------------------
|
|
720
|
+
// 関連資料 (波 VIII)。読取専用 2 API のみ・書込経路ゼロ。containment 監査は S-6
|
|
721
|
+
// ---------------------------------------------------------------------
|
|
722
|
+
// GET /api/docs/tree — docsDirs 配下の .md ツリー (symlink 非追従)
|
|
723
|
+
app.get('/api/docs/tree', async (c) => {
|
|
724
|
+
const { config } = await loadConfig(projectRoot);
|
|
725
|
+
return c.json({ trees: await buildDocsTree(projectRoot, config) });
|
|
726
|
+
});
|
|
727
|
+
// GET /api/docs?path=... — 資料本文 (読取専用)。範囲外 403 / 不在 404
|
|
728
|
+
app.get('/api/docs', async (c) => {
|
|
729
|
+
const relPath = c.req.query('path');
|
|
730
|
+
if (relPath === undefined || relPath === '') {
|
|
731
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
732
|
+
}
|
|
733
|
+
const { config } = await loadConfig(projectRoot);
|
|
734
|
+
try {
|
|
735
|
+
return c.json(await readDoc(projectRoot, config, relPath));
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
if (error instanceof PathValidationError)
|
|
739
|
+
return c.json({ error: error.message }, 403);
|
|
740
|
+
if (error.code === 'ENOENT')
|
|
741
|
+
return c.json({ error: '資料が見つかりません' }, 404);
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
// ---------------------------------------------------------------------
|
|
746
|
+
// GET /api/tasks/:id/commits — タスク ID を含む git コミット (S-1・read-only)
|
|
747
|
+
// ID をパスから取得 → taskIdSchema で検証 → git.ts が更に厳格バリデーション + execFile (シェル非経由)。
|
|
748
|
+
// 非 git / git 未インストール / 該当なしは [] (graceful degradation・N-1 はローカル subprocess のみ)。
|
|
749
|
+
// ---------------------------------------------------------------------
|
|
750
|
+
app.get('/api/tasks/:id/commits', async (c) => {
|
|
751
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
752
|
+
if (!idResult.success) {
|
|
753
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
754
|
+
}
|
|
755
|
+
const commits = await findCommitsForTask(projectRoot, idResult.data);
|
|
756
|
+
return c.json({ commits });
|
|
757
|
+
});
|
|
758
|
+
// ---------------------------------------------------------------------
|
|
759
|
+
// POST /api/tasks/:id/comment — タスクコメント (往復・本文 ## やりとり に追記)
|
|
760
|
+
// ---------------------------------------------------------------------
|
|
761
|
+
app.post('/api/tasks/:id/comment', async (c) => {
|
|
762
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
763
|
+
if (!idResult.success) {
|
|
764
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
765
|
+
}
|
|
766
|
+
const input = commentInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
767
|
+
if (!input.success) {
|
|
768
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
769
|
+
}
|
|
770
|
+
// コメントの member は config.members に登録済みであること (作業ログ / 起票と揃える、R8 Minor)
|
|
771
|
+
const { config } = await loadConfig(projectRoot);
|
|
772
|
+
if (!config.members.some((m) => m.id === input.data.member)) {
|
|
773
|
+
return c.json({ error: `member "${input.data.member}" は config.members に未登録` }, 400);
|
|
774
|
+
}
|
|
775
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
776
|
+
return enqueue(async () => {
|
|
777
|
+
let raw;
|
|
778
|
+
try {
|
|
779
|
+
raw = await readKanbanFile(filePath, await getKanbanReal()); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
783
|
+
}
|
|
784
|
+
let current;
|
|
785
|
+
try {
|
|
786
|
+
current = parseTaskFile(raw);
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
790
|
+
return c.json({ error: `コメントできない (${message})` }, 409);
|
|
791
|
+
}
|
|
792
|
+
// 本文の ## やりとり セクションに `- 時刻 [member] msg` を追記する (frontmatter はバイト保全)
|
|
793
|
+
const line = `${workLogTimestamp()} [${input.data.member}] ${input.data.message}`;
|
|
794
|
+
const newBody = appendCommentLine(current.body, line);
|
|
795
|
+
await safeWriteFile(filePath, replaceBody(raw, newBody), [kanbanDir]);
|
|
796
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
// ---------------------------------------------------------------------
|
|
800
|
+
// GET /api/events — SSE (通知のみ。クライアントは board を再取得する)
|
|
801
|
+
// ---------------------------------------------------------------------
|
|
802
|
+
app.get('/api/events', (c) => {
|
|
803
|
+
return streamSSE(c, async (stream) => {
|
|
804
|
+
let unsubscribe = () => { };
|
|
805
|
+
const closed = new Promise((resolve) => {
|
|
806
|
+
stream.onAbort(() => resolve());
|
|
807
|
+
});
|
|
808
|
+
unsubscribe = bus.subscribe((event) => {
|
|
809
|
+
void stream.writeSSE({ data: JSON.stringify(event) });
|
|
810
|
+
});
|
|
811
|
+
await stream.writeSSE({ data: JSON.stringify({ type: 'connected' }) });
|
|
812
|
+
await closed;
|
|
813
|
+
unsubscribe();
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
return { app, bus };
|
|
817
|
+
}
|