aizuchi 0.1.0 → 0.3.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/CHANGELOG.md +121 -0
- package/README.md +79 -18
- package/dist/bin/aizuchi.js +9 -1
- package/dist/server/app.js +392 -10
- package/dist/server/board.js +35 -5
- package/dist/server/lib/config-paths.js +60 -19
- package/dist/server/lib/files.js +56 -21
- package/dist/server/lib/pinpoint.js +50 -0
- package/dist/server/lib/proposal.js +10 -0
- package/dist/server/lib/task-ops.js +102 -2
- package/dist/server/lib/trash.js +122 -0
- package/dist/server/watcher.js +17 -4
- package/dist/shared/board-diff.js +48 -0
- package/dist/shared/diff.js +16 -0
- package/dist/shared/schema.js +125 -10
- package/dist/shared/stats.js +117 -1
- package/dist/web/assets/index-B4RPR1mO.css +1 -0
- package/dist/web/assets/index-DG0paXSh.js +93 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/templates/config.json +8 -7
- package/dist/web/assets/index-BLN_aXBe.css +0 -1
- package/dist/web/assets/index-BS_n8d9B.js +0 -82
package/dist/server/app.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import { promises as fs, existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { Hono } from 'hono';
|
|
4
5
|
import { bodyLimit } from 'hono/body-limit';
|
|
5
6
|
import { HTTPException } from 'hono/http-exception';
|
|
6
7
|
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 { commentInputSchema, configSchema, fileSaveInputSchema, memberRenameInputSchema, renameInputSchema, findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, rejectInputSchema, restoreInputSchema, reviseInputSchema, saveInputSchema, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../shared/schema.js';
|
|
8
9
|
import { loadArchive, loadBoard, loadConfig, loadTaskView } from './board.js';
|
|
9
10
|
import { createEventBus } from './events.js';
|
|
10
11
|
import { allocateNextId, createSerialQueue } from './lib/id.js';
|
|
11
|
-
import { assertDirInsideKanban, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
12
|
+
import { assertDirInsideKanban, collectKanbanFilesNoSymlink, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
12
13
|
import { buildDocsTree, readDoc } from './lib/docs.js';
|
|
13
14
|
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';
|
|
15
|
+
import { buildCatalog, buildFileTree, buildGlobalCatalog, fileMtime, globalFileMtime, readConfigFile, readGlobalConfigFile, saveConfigFile, saveGlobalConfigFile, } from './lib/files.js';
|
|
16
|
+
import { applyPinpointPatch, renameTaskId, replaceDependsOnId, replaceMemberKeys, verifyMemberRename, verifyPinpointResult, verifyRename, } from './lib/pinpoint.js';
|
|
17
|
+
import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './lib/proposal.js';
|
|
18
|
+
import { REVISABLE_STATUSES } from './lib/task-ops.js';
|
|
17
19
|
import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './lib/task-file.js';
|
|
18
20
|
import { archiveMonth, nowIso, workLogTimestamp } from './lib/time.js';
|
|
21
|
+
import { DEFAULT_TRASH_KEEP, listTrash, snapshotToTrash } from './lib/trash.js';
|
|
19
22
|
/**
|
|
20
23
|
* Hono アプリ (REST API + SSE)。要件 §6.7 のうち Phase 1 スコープの
|
|
21
24
|
* board / tasks / events のみを実装する (アーカイブ・承認・却下 API は Phase 2)。
|
|
@@ -125,6 +128,54 @@ export function createApp(options) {
|
|
|
125
128
|
await fs.rm(path.join(tasksDir, `${id}.md`), { force: true });
|
|
126
129
|
return path.relative(projectRoot, archivePath);
|
|
127
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* archive/**\/<id>.md を tasks/<id>.md へ復元する (moveToArchive の対称・unarchive)。
|
|
133
|
+
* 「復元先 (tasks/) を排他作成 → 成功後に archive 元を rm」順序でタスク消失を防ぐ (archive と対称)。
|
|
134
|
+
* archive 配下に id 一致が無ければ null を返す (呼び出し側で 404)。tasks/ に既存があれば
|
|
135
|
+
* safeWriteFile の exclusive が FileExistsError を投げる (呼び出し側で 409)。
|
|
136
|
+
*
|
|
137
|
+
* パス安全 (N-2): archive ルートにも resolveKanbanReal で .kanban の realpath 信頼境界を確立し、
|
|
138
|
+
* collectKanbanFilesNoSymlink (realpath containment + symlink 非追従) で探す。読出は readKanbanFile、
|
|
139
|
+
* 書込は safeWriteFile (許可リスト = .kanban) で、symlink / 中間 dir symlink 経由の外部読書を遮断する。
|
|
140
|
+
*/
|
|
141
|
+
const restoreFromArchive = async (id) => {
|
|
142
|
+
const kanbanReal = await getKanbanReal(); // .kanban 自体が symlink なら throw
|
|
143
|
+
const archiveDir = path.join(kanbanReal, 'archive');
|
|
144
|
+
// archive 配下を symlink 非追従 + realpath containment で走査し、id 一致 (<id>.md) を探す
|
|
145
|
+
const files = await collectKanbanFilesNoSymlink(archiveDir, kanbanReal);
|
|
146
|
+
const archivePath = files.find((abs) => path.basename(abs).slice(0, -3) === id);
|
|
147
|
+
if (archivePath === undefined)
|
|
148
|
+
return false; // 不在 → 404
|
|
149
|
+
const content = await readKanbanFile(archivePath, kanbanReal); // 中間 dir / 最終 symlink を遮断
|
|
150
|
+
const destPath = path.join(tasksDir, `${id}.md`);
|
|
151
|
+
// 復元先を排他作成 (既存があれば FileExistsError → 409)。成功後に archive 元を消す (消失防止)
|
|
152
|
+
await safeWriteFile(destPath, content, [kanbanDir], { exclusive: true });
|
|
153
|
+
await fs.rm(archivePath, { force: true });
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* 破壊的書込 (PATCH/PUT/approve/revise/rename/member rename/comment) の直前に、現内容を
|
|
158
|
+
* `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する安全網 (ごみ箱)。誤上書き / 誤操作からの復元用。
|
|
159
|
+
*
|
|
160
|
+
* - **ベストエフォート**: 退避失敗 (try/catch) は本処理を止めない。退避は元を消さない「コピー」なので、
|
|
161
|
+
* 退避が出来なくても従来どおり書込は進む (機能追加で既存挙動を壊さない)。
|
|
162
|
+
* - **comment / log 追記も退避する (SEC-N3・2026-06-16)**: append 系も実装は read→全文上書きのため、
|
|
163
|
+
* 外部編集と交錯して上書きで失われた場合に復元できるよう、他の破壊的書込と同様に直前退避する。
|
|
164
|
+
* - trashKeep は config 由来 (既定 DEFAULT_TRASH_KEEP)。世代刈り取りは snapshotToTrash 内で行う。
|
|
165
|
+
* - id は退避ファイル名 (basename) に使う。連鎖更新 (rename の readdir 由来 name.slice) や member rename
|
|
166
|
+
* 経由では taskIdSchema 未検証の値が渡るが、退避先 basename は safeWriteFile が SAFE_BASENAME_RE +
|
|
167
|
+
* containment で再検証するためパス安全 (検証済み前提に依存しない)。
|
|
168
|
+
*/
|
|
169
|
+
const snapshot = async (id, currentContent, kanbanReal) => {
|
|
170
|
+
try {
|
|
171
|
+
const { config } = await loadConfig(projectRoot);
|
|
172
|
+
const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
|
|
173
|
+
await snapshotToTrash(id, currentContent, kanbanDir, kanbanReal, keep);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
|
|
177
|
+
}
|
|
178
|
+
};
|
|
128
179
|
// セキュリティミドルウェア (CSP / Host / CSRF / bodyLimit) は overview と共通 (R14-5)
|
|
129
180
|
applySecurityMiddleware(app);
|
|
130
181
|
app.onError((error, c) => {
|
|
@@ -284,6 +335,7 @@ export function createApp(options) {
|
|
|
284
335
|
if (!check.success) {
|
|
285
336
|
return c.json({ error: 'ステータス変更後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
286
337
|
}
|
|
338
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
287
339
|
await safeWriteFile(filePath, patched, [kanbanDir]);
|
|
288
340
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
289
341
|
});
|
|
@@ -335,6 +387,7 @@ export function createApp(options) {
|
|
|
335
387
|
if (check.data.id !== idResult.data) {
|
|
336
388
|
return c.json({ error: `frontmatter の id (${check.data.id}) は変更できない` }, 400);
|
|
337
389
|
}
|
|
390
|
+
await snapshot(idResult.data, serverContent, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
338
391
|
await safeWriteFile(filePath, input.data.content, [kanbanDir]);
|
|
339
392
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
340
393
|
});
|
|
@@ -393,6 +446,7 @@ export function createApp(options) {
|
|
|
393
446
|
// モード違反検知 (board.ts) の対象から外れる
|
|
394
447
|
const logLine = `${workLogTimestamp()} [approve] 承認: proposed → todo`;
|
|
395
448
|
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
449
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
396
450
|
await safeWriteFile(filePath, finalRaw, [kanbanDir]);
|
|
397
451
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
398
452
|
});
|
|
@@ -489,6 +543,237 @@ export function createApp(options) {
|
|
|
489
543
|
});
|
|
490
544
|
});
|
|
491
545
|
// ---------------------------------------------------------------------
|
|
546
|
+
// POST /api/tasks/:id/unarchive — archive のタスクを board (tasks/) へ復元 (moveToArchive の対称)
|
|
547
|
+
// archive/**/<id>.md を探して tasks/<id>.md へ排他作成し、成功後に archive 元を rm する (消失防止)。
|
|
548
|
+
// 不在は 404 / 復元先 (tasks/) 衝突は 409。本文は変更せずそのまま戻す (所在のみ board へ移す)。
|
|
549
|
+
// ---------------------------------------------------------------------
|
|
550
|
+
app.post('/api/tasks/:id/unarchive', async (c) => {
|
|
551
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
552
|
+
if (!idResult.success) {
|
|
553
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
554
|
+
}
|
|
555
|
+
return enqueue(async () => {
|
|
556
|
+
try {
|
|
557
|
+
const found = await restoreFromArchive(idResult.data);
|
|
558
|
+
if (!found) {
|
|
559
|
+
return c.json({ error: `archive にタスクが見つからない: ${idResult.data}` }, 404);
|
|
560
|
+
}
|
|
561
|
+
// 復元 (ファイル移動) は完了済み。応答用の loadTaskView は壊れた frontmatter で throw しうるが、
|
|
562
|
+
// その場合でも復元自体は成功しているので 500 にせず軽量応答を返す (board は lenient parse で警告表示)。
|
|
563
|
+
try {
|
|
564
|
+
return c.json(await loadTaskView(projectRoot, path.join(tasksDir, `${idResult.data}.md`)));
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return c.json({ id: idResult.data, restored: true });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (error instanceof FileExistsError) {
|
|
572
|
+
return c.json({ error: `復元先に同じ ID が既に存在します: ${idResult.data}` }, 409);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
// ---------------------------------------------------------------------
|
|
579
|
+
// GET /api/trash — ごみ箱の退避一覧 (read-only)。破壊的書込の直前に .trash へコピー退避した旧版を
|
|
580
|
+
// 軽量メタ (id / trashedAt / title / status) + プレビュー用本文で返す。新しい退避が先頭。
|
|
581
|
+
// N-2: .trash も resolveKanbanReal + collectKanbanFilesNoSymlink + readKanbanFile (containment + symlink 非追従)。
|
|
582
|
+
// .kanban が symlink / .trash 不在は空一覧に倒す (graceful)。
|
|
583
|
+
// ---------------------------------------------------------------------
|
|
584
|
+
app.get('/api/trash', async (c) => {
|
|
585
|
+
let kanbanReal;
|
|
586
|
+
try {
|
|
587
|
+
kanbanReal = await getKanbanReal(); // .kanban 自体が symlink / 不在なら throw → 空一覧
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return c.json({ items: [] });
|
|
591
|
+
}
|
|
592
|
+
const entries = await listTrash(kanbanDir, kanbanReal);
|
|
593
|
+
// 新しい退避が先頭 (trashedAt 降順)。同 epoch は id で安定化
|
|
594
|
+
entries.sort((a, b) => b.trashedAt - a.trashedAt || a.id.localeCompare(b.id));
|
|
595
|
+
const items = [];
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
let raw;
|
|
598
|
+
try {
|
|
599
|
+
raw = await readKanbanFile(entry.absPath, kanbanReal); // containment + symlink 非追従
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
continue; // 走査と読出の間に消えた / symlink はスキップ
|
|
603
|
+
}
|
|
604
|
+
// frontmatter が parse できれば title / status を出す。壊れていても本文は出せる限り出す
|
|
605
|
+
let title = null;
|
|
606
|
+
let status = null;
|
|
607
|
+
let body = raw;
|
|
608
|
+
try {
|
|
609
|
+
const parsed = parseTaskFile(raw);
|
|
610
|
+
body = parsed.body;
|
|
611
|
+
const fm = taskFrontmatterSchema.safeParse(parsed.data);
|
|
612
|
+
if (fm.success) {
|
|
613
|
+
title = fm.data.title;
|
|
614
|
+
status = fm.data.status;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// parse 不能 (YAML 構文エラー等) は title/status=null・body=全文
|
|
619
|
+
}
|
|
620
|
+
items.push({ id: entry.id, trashedAt: entry.trashedAt, title, status, body });
|
|
621
|
+
}
|
|
622
|
+
return c.json({ items });
|
|
623
|
+
});
|
|
624
|
+
// ---------------------------------------------------------------------
|
|
625
|
+
// POST /api/tasks/:id/restore — ごみ箱の退避版を tasks/<id>.md へ書き戻す (誤上書きからの復元)。
|
|
626
|
+
// body.trashedAt 指定でその世代、省略時は当該 id の最新世代を復元する。
|
|
627
|
+
// tasks/<id>.md が既存のとき: overwrite=true なら現行を .trash へ退避してから上書き復元、
|
|
628
|
+
// overwrite 未指定 / false なら 409 (上書きしない・unarchive と対称)。退避が無ければ 404。
|
|
629
|
+
// N-2: .trash 走査 (collectKanbanFilesNoSymlink) + 読出 (readKanbanFile) + 書込 (safeWriteFile)。
|
|
630
|
+
// 復元は退避版を消さない (コピー・世代として残す。本処理が再び snapshot を積むのとも整合)。
|
|
631
|
+
// ---------------------------------------------------------------------
|
|
632
|
+
app.post('/api/tasks/:id/restore', async (c) => {
|
|
633
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
634
|
+
if (!idResult.success) {
|
|
635
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
636
|
+
}
|
|
637
|
+
const input = restoreInputSchema.safeParse(await c.req.json().catch(() => ({})));
|
|
638
|
+
if (!input.success) {
|
|
639
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
640
|
+
}
|
|
641
|
+
const id = idResult.data;
|
|
642
|
+
return enqueue(async () => {
|
|
643
|
+
const kanbanReal = await getKanbanReal(); // .kanban 自体が symlink なら throw → onError で 400
|
|
644
|
+
// 当該 id の退避世代を集め、指定 (trashedAt) or 最新 (epoch 最大) を選ぶ
|
|
645
|
+
const generations = (await listTrash(kanbanDir, kanbanReal, id)).sort((a, b) => b.trashedAt - a.trashedAt);
|
|
646
|
+
const target = input.data.trashedAt !== undefined
|
|
647
|
+
? generations.find((g) => g.trashedAt === input.data.trashedAt)
|
|
648
|
+
: generations[0];
|
|
649
|
+
if (target === undefined) {
|
|
650
|
+
return c.json({ error: `ごみ箱に退避が見つからない: ${id}` }, 404);
|
|
651
|
+
}
|
|
652
|
+
let content;
|
|
653
|
+
try {
|
|
654
|
+
content = await readKanbanFile(target.absPath, kanbanReal); // containment + symlink 非追従
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return c.json({ error: `退避を読み込めない: ${id}` }, 404);
|
|
658
|
+
}
|
|
659
|
+
const destPath = path.join(tasksDir, `${id}.md`);
|
|
660
|
+
if (input.data.overwrite === true) {
|
|
661
|
+
// 上書き復元: 現行があれば破壊的書込なので、まず現行を .trash へ退避してから非排他で上書き。
|
|
662
|
+
// 退避版は消さず世代として残す (コピー復元) + 現行も新たな世代として退避され取り戻せる。
|
|
663
|
+
let current = null;
|
|
664
|
+
try {
|
|
665
|
+
current = await readKanbanFile(destPath, kanbanReal);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
current = null; // 現行が無い / 読めない (壊れ) は退避不要でそのまま上書き復元
|
|
669
|
+
}
|
|
670
|
+
if (current !== null) {
|
|
671
|
+
await snapshot(id, current, kanbanReal); // 現行を .trash へ退避 (ベストエフォート)
|
|
672
|
+
}
|
|
673
|
+
await safeWriteFile(destPath, content, [kanbanDir]);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
try {
|
|
677
|
+
// 復元先を排他作成 (既存があれば 409)。退避版は消さず世代として残す (コピー復元)
|
|
678
|
+
await safeWriteFile(destPath, content, [kanbanDir], { exclusive: true });
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (error instanceof FileExistsError) {
|
|
682
|
+
return c.json({ error: `復元先に同じ ID が既に存在します: ${id}` }, 409);
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// 復元自体は完了済み。応答用 loadTaskView は壊れた frontmatter で throw しうるので軽量応答に倒す
|
|
688
|
+
try {
|
|
689
|
+
return c.json(await loadTaskView(projectRoot, destPath));
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return c.json({ id, restored: true });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
// ---------------------------------------------------------------------
|
|
697
|
+
// POST /api/tasks/:id/revise — やり直し依頼 (差し戻し)。todo/in_progress/pending/done → todo へ戻し、
|
|
698
|
+
// 理由を作業ログに残す。pending から戻す場合は blocked_* を null へ (reject の blocked クリア相当)。
|
|
699
|
+
// 既に todo の場合も理由ログだけは追記する (status 変更なしでも差し戻し意図を残せる)。
|
|
700
|
+
// proposed / backlog は対象外 (approve 承認フロー迂回防止のため 409。REVISABLE_STATUSES 参照)。
|
|
701
|
+
// ---------------------------------------------------------------------
|
|
702
|
+
app.post('/api/tasks/:id/revise', async (c) => {
|
|
703
|
+
const idResult = taskIdSchema.safeParse(c.req.param('id'));
|
|
704
|
+
if (!idResult.success) {
|
|
705
|
+
return c.json({ error: '不正なタスク ID' }, 400);
|
|
706
|
+
}
|
|
707
|
+
const input = reviseInputSchema.safeParse(await c.req.json().catch(() => null));
|
|
708
|
+
if (!input.success) {
|
|
709
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
710
|
+
}
|
|
711
|
+
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
712
|
+
return enqueue(async () => {
|
|
713
|
+
let stat;
|
|
714
|
+
let raw;
|
|
715
|
+
try {
|
|
716
|
+
// R17-D: realpath containment + symlink 非追従で stat/read する (symlink/中間dirは 404)。
|
|
717
|
+
// mtime 競合検知のため stat も取得する (PUT /api/tasks と同方式)
|
|
718
|
+
const kanbanReal = await getKanbanReal();
|
|
719
|
+
stat = await statKanbanFile(filePath, kanbanReal);
|
|
720
|
+
raw = await readKanbanFile(filePath, kanbanReal);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
724
|
+
}
|
|
725
|
+
// baseMtimeMs があれば mtime 厳密比較 (N-3、PATCH/PUT と同じく外部変更を 409 で弾く)
|
|
726
|
+
if (input.data.baseMtimeMs !== undefined && stat.mtimeMs !== input.data.baseMtimeMs) {
|
|
727
|
+
return c.json({ error: '外部で変更されています', serverContent: raw, serverMtimeMs: stat.mtimeMs }, 409);
|
|
728
|
+
}
|
|
729
|
+
let current;
|
|
730
|
+
try {
|
|
731
|
+
current = parseTaskFile(raw);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
const message = error instanceof TaskFileParseError ? error.message : 'タスクファイルを解析できない';
|
|
735
|
+
return c.json({ error: `差し戻しできない (${message})` }, 409);
|
|
736
|
+
}
|
|
737
|
+
// 源 status ガード: 差し戻しは「進行中・保留・完了・既に todo」のタスクのみを対象にする。
|
|
738
|
+
// proposed / backlog から todo へ直接行けると approve 承認フロー (proposed→todo) を迂回し、
|
|
739
|
+
// 恒久 mode_violation を生む。approve/reject が status==='proposed' を必須化するのと対称。
|
|
740
|
+
if (!REVISABLE_STATUSES.has(current.data.status)) {
|
|
741
|
+
return c.json({ error: 'この状態のタスクは差し戻せません' }, 409);
|
|
742
|
+
}
|
|
743
|
+
// status を todo へ行置換 (本文・他フィールドは保全)。pending から戻す場合は
|
|
744
|
+
// blocked_* も null へ戻す (reject ハンドラの blocked クリア相当)。verify-after-patch を通す
|
|
745
|
+
const patch = { status: 'todo', updated: nowIso() };
|
|
746
|
+
if (current.data.blocked_type != null || current.data.blocked_reason != null) {
|
|
747
|
+
patch.blocked_type = null;
|
|
748
|
+
patch.blocked_reason = null;
|
|
749
|
+
}
|
|
750
|
+
let patched;
|
|
751
|
+
let patchedParsed;
|
|
752
|
+
try {
|
|
753
|
+
patched = applyPinpointPatch(raw, patch);
|
|
754
|
+
patchedParsed = parseTaskFile(patched);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
const message = error instanceof TaskFileParseError ? error.message : error instanceof Error ? error.message : '差し戻しに失敗';
|
|
758
|
+
return c.json({ error: `差し戻しできない (${message})` }, 409);
|
|
759
|
+
}
|
|
760
|
+
const drift = verifyPinpointResult(current, patchedParsed, patch);
|
|
761
|
+
if (drift !== null) {
|
|
762
|
+
return c.json({ error: `差し戻しできない (${drift})` }, 409);
|
|
763
|
+
}
|
|
764
|
+
const check = taskFrontmatterSchema.safeParse(patchedParsed.data);
|
|
765
|
+
if (!check.success) {
|
|
766
|
+
return c.json({ error: '差し戻し後のタスクが不正', issues: summarizeZodError(check.error) }, 400);
|
|
767
|
+
}
|
|
768
|
+
// 差し戻し理由を作業ログに残す (frontmatter は pinpoint 済みなので body だけ更新する。approve と同型)
|
|
769
|
+
const logLine = `${workLogTimestamp()} [human] ${reviseWorkLogText(input.data.reason)}`;
|
|
770
|
+
const finalRaw = replaceBody(patched, appendWorkLogLine(patchedParsed.body, logLine));
|
|
771
|
+
await snapshot(idResult.data, raw, await getKanbanReal()); // 旧版を .trash へ退避 (ベストエフォート)
|
|
772
|
+
await safeWriteFile(filePath, finalRaw, [kanbanDir]);
|
|
773
|
+
return c.json(await loadTaskView(projectRoot, filePath));
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
// ---------------------------------------------------------------------
|
|
492
777
|
// POST /api/tasks/:id/rename — 重複 ID を空き番号へリネーム (F-6-5)
|
|
493
778
|
// ---------------------------------------------------------------------
|
|
494
779
|
app.post('/api/tasks/:id/rename', async (c) => {
|
|
@@ -571,6 +856,9 @@ export function createApp(options) {
|
|
|
571
856
|
}
|
|
572
857
|
const updated = replaceDependsOnId(content, oldId, newId);
|
|
573
858
|
if (updated !== content) {
|
|
859
|
+
// 連鎖更新は他タスクの depends_on を破壊的に書き換えるので、その旧版も退避する
|
|
860
|
+
// (旧 id のリネーム自体は newPath に同内容が残るため退避不要・audit 参照)
|
|
861
|
+
await snapshot(name.slice(0, -3), content, chainKanbanReal);
|
|
574
862
|
await safeWriteFile(p, updated, [kanbanDir]);
|
|
575
863
|
}
|
|
576
864
|
}
|
|
@@ -578,6 +866,84 @@ export function createApp(options) {
|
|
|
578
866
|
});
|
|
579
867
|
});
|
|
580
868
|
// ---------------------------------------------------------------------
|
|
869
|
+
// POST /api/members/rename — member スラッグ変更 / 削除時の移譲。
|
|
870
|
+
// 全タスク (tasks/ のみ。archive は歴史として触らない) の created_by / assignee の
|
|
871
|
+
// from を to へ付け替える。作業ログ内の [member] 表記も歴史なので書き換えない。
|
|
872
|
+
// updated は更新しない (メタデータ移行であり経過時間表示を汚さない)。
|
|
873
|
+
// ---------------------------------------------------------------------
|
|
874
|
+
app.post('/api/members/rename', async (c) => {
|
|
875
|
+
const input = memberRenameInputSchema.safeParse(await c.req.json().catch(() => ({})));
|
|
876
|
+
if (!input.success) {
|
|
877
|
+
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
878
|
+
}
|
|
879
|
+
const { from, to } = input.data;
|
|
880
|
+
return enqueue(async () => {
|
|
881
|
+
const kanbanReal = await getKanbanReal(); // ループ前に realpath 信頼境界を一度確立 (F-6-5 連鎖更新と同じ)
|
|
882
|
+
let names = [];
|
|
883
|
+
try {
|
|
884
|
+
names = await fs.readdir(tasksDir);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
names = [];
|
|
888
|
+
}
|
|
889
|
+
let renamed = 0;
|
|
890
|
+
const skipped = [];
|
|
891
|
+
for (const name of names) {
|
|
892
|
+
if (!name.endsWith('.md'))
|
|
893
|
+
continue;
|
|
894
|
+
const p = path.join(tasksDir, name);
|
|
895
|
+
let content;
|
|
896
|
+
try {
|
|
897
|
+
content = await readKanbanFile(p, kanbanReal); // containment + symlink 非追従 (symlink は対象外)
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
let before;
|
|
903
|
+
try {
|
|
904
|
+
before = parseTaskFile(content);
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
continue; // 破損ファイルは対象外 (既存の invalid_task 警告に任せる)
|
|
908
|
+
}
|
|
909
|
+
const keys = [];
|
|
910
|
+
if (before.data.created_by === from)
|
|
911
|
+
keys.push('created_by');
|
|
912
|
+
if (before.data.assignee === from)
|
|
913
|
+
keys.push('assignee');
|
|
914
|
+
if (keys.length === 0)
|
|
915
|
+
continue;
|
|
916
|
+
const patched = replaceMemberKeys(content, keys, to);
|
|
917
|
+
let after;
|
|
918
|
+
try {
|
|
919
|
+
after = parseTaskFile(patched);
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
skipped.push(name);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
// verify-after-patch: 対象キーだけが to に変わり他フィールド/本文が不変かを書込前に検証。
|
|
926
|
+
// 未対応表記は書き換えずスキップし、応答で列挙する (silent な取りこぼしを可視化)
|
|
927
|
+
const drift = verifyMemberRename(before, after, keys, to);
|
|
928
|
+
if (drift !== null) {
|
|
929
|
+
skipped.push(name);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
// 書込失敗 (fs エラー等) も全体を中断させず skipped に積んで続行する —
|
|
933
|
+
// 途中失敗で renamed/skipped の進捗が失われると部分適用の把握ができなくなる
|
|
934
|
+
try {
|
|
935
|
+
await snapshot(name.slice(0, -3), content, kanbanReal); // 旧版 (created_by/assignee 改変前) を .trash へ退避
|
|
936
|
+
await safeWriteFile(p, patched, [kanbanDir]);
|
|
937
|
+
renamed += 1;
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
skipped.push(name);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return c.json({ renamed, skipped });
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
// ---------------------------------------------------------------------
|
|
581
947
|
// GET /api/config — 生 config JSON を返す (設定画面 F-5 の編集元)
|
|
582
948
|
// ---------------------------------------------------------------------
|
|
583
949
|
app.get('/api/config', async (c) => {
|
|
@@ -670,8 +1036,12 @@ export function createApp(options) {
|
|
|
670
1036
|
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
671
1037
|
}
|
|
672
1038
|
const { config } = await loadConfig(projectRoot);
|
|
1039
|
+
const isGlobal = c.req.query('scope') === 'global';
|
|
673
1040
|
try {
|
|
674
|
-
|
|
1041
|
+
const result = isGlobal
|
|
1042
|
+
? await readGlobalConfigFile(os.homedir(), relPath)
|
|
1043
|
+
: await readConfigFile(projectRoot, config, relPath);
|
|
1044
|
+
return c.json(result);
|
|
675
1045
|
}
|
|
676
1046
|
catch (error) {
|
|
677
1047
|
if (error instanceof PathValidationError)
|
|
@@ -688,15 +1058,20 @@ export function createApp(options) {
|
|
|
688
1058
|
return c.json({ error: '入力が不正', issues: summarizeZodError(input.error) }, 400);
|
|
689
1059
|
}
|
|
690
1060
|
const { config } = await loadConfig(projectRoot);
|
|
1061
|
+
const isGlobal = input.data.scope === 'global';
|
|
691
1062
|
// 直列化キューで mtime 検査 → 保存 (プロセス内 write-write 競合を閉じる)
|
|
692
1063
|
return enqueue(async () => {
|
|
693
1064
|
try {
|
|
694
|
-
const current =
|
|
1065
|
+
const current = isGlobal
|
|
1066
|
+
? await globalFileMtime(os.homedir(), input.data.path)
|
|
1067
|
+
: await fileMtime(projectRoot, config, input.data.path);
|
|
695
1068
|
// 既存ファイルの場合のみ mtime 厳密比較 (新規作成は current=null で素通り)
|
|
696
1069
|
if (current !== null && current !== input.data.baseMtimeMs) {
|
|
697
1070
|
return c.json({ error: '外部で変更されています', serverMtimeMs: current }, 409);
|
|
698
1071
|
}
|
|
699
|
-
const saved =
|
|
1072
|
+
const saved = isGlobal
|
|
1073
|
+
? await saveGlobalConfigFile(os.homedir(), input.data.path, input.data.content)
|
|
1074
|
+
: await saveConfigFile(projectRoot, config, input.data.path, input.data.content);
|
|
700
1075
|
return c.json(saved);
|
|
701
1076
|
}
|
|
702
1077
|
catch (error) {
|
|
@@ -711,8 +1086,11 @@ export function createApp(options) {
|
|
|
711
1086
|
const { config } = await loadConfig(projectRoot);
|
|
712
1087
|
return c.json({ trees: await buildFileTree(projectRoot, config) });
|
|
713
1088
|
});
|
|
714
|
-
// GET /api/files/catalog — skill / agent の frontmatter カタログ (F-4-4)
|
|
1089
|
+
// GET /api/files/catalog — skill / agent の frontmatter カタログ (F-4-4)。scope=global で ~/.claude/skills,agents
|
|
715
1090
|
app.get('/api/files/catalog', async (c) => {
|
|
1091
|
+
if (c.req.query('scope') === 'global') {
|
|
1092
|
+
return c.json({ items: await buildGlobalCatalog(os.homedir()) });
|
|
1093
|
+
}
|
|
716
1094
|
const { config } = await loadConfig(projectRoot);
|
|
717
1095
|
return c.json({ items: await buildCatalog(projectRoot, config) });
|
|
718
1096
|
});
|
|
@@ -774,9 +1152,10 @@ export function createApp(options) {
|
|
|
774
1152
|
}
|
|
775
1153
|
const filePath = path.join(tasksDir, `${idResult.data}.md`);
|
|
776
1154
|
return enqueue(async () => {
|
|
1155
|
+
const kanbanReal = await getKanbanReal();
|
|
777
1156
|
let raw;
|
|
778
1157
|
try {
|
|
779
|
-
raw = await readKanbanFile(filePath,
|
|
1158
|
+
raw = await readKanbanFile(filePath, kanbanReal); // R17-D: containment + symlink 非追従 (symlink/中間dirは 404)
|
|
780
1159
|
}
|
|
781
1160
|
catch {
|
|
782
1161
|
return c.json({ error: `タスクが見つからない: ${idResult.data}` }, 404);
|
|
@@ -792,6 +1171,9 @@ export function createApp(options) {
|
|
|
792
1171
|
// 本文の ## やりとり セクションに `- 時刻 [member] msg` を追記する (frontmatter はバイト保全)
|
|
793
1172
|
const line = `${workLogTimestamp()} [${input.data.member}] ${input.data.message}`;
|
|
794
1173
|
const newBody = appendCommentLine(current.body, line);
|
|
1174
|
+
// SEC-N3: コメント追記も read→全文上書きのため、上書き直前に現内容を .trash へ退避する
|
|
1175
|
+
// (他の破壊的書込経路と同様)。外部編集と交錯して失われても復元できるようにする (ベストエフォート)。
|
|
1176
|
+
await snapshot(idResult.data, raw, kanbanReal);
|
|
795
1177
|
await safeWriteFile(filePath, replaceBody(raw, newBody), [kanbanDir]);
|
|
796
1178
|
return c.json(await loadTaskView(projectRoot, filePath));
|
|
797
1179
|
});
|
package/dist/server/board.js
CHANGED
|
@@ -63,10 +63,19 @@ async function collectArchiveIds(kanbanDir, kanbanReal) {
|
|
|
63
63
|
}
|
|
64
64
|
return ids;
|
|
65
65
|
}
|
|
66
|
+
/** archive/<...>/<id>.md の絶対パス + kanbanReal から所在月 <YYYY-MM> を抽出する (無ければ null) */
|
|
67
|
+
function extractArchiveMonth(abs, kanbanReal) {
|
|
68
|
+
const archiveRoot = path.join(kanbanReal, 'archive');
|
|
69
|
+
const rel = path.relative(archiveRoot, abs); // 例: 2026-06/TASK-001.md
|
|
70
|
+
const first = rel.split(path.sep)[0];
|
|
71
|
+
return /^\d{4}-\d{2}$/.test(first) ? first : null;
|
|
72
|
+
}
|
|
66
73
|
/**
|
|
67
|
-
* archive/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* archive/** の全タスクを読む。`id` / `body` は常に返し (後方互換)、frontmatter が parse でき検証を
|
|
75
|
+
* 通れば表示用フィールド (title / status / assignee / labels / updated) と所在月 month を追加する。
|
|
76
|
+
* frontmatter が壊れていても本文は取り出せる限り返す (id + body + month のみ・title 等は欠落許容)。
|
|
77
|
+
* 本文も parse できない (YAML 構文エラー等) ファイルや symlink はスキップする。
|
|
78
|
+
* 検証はせず所在で表現する点は従来通り (アーカイブは様々な status がありうる)。
|
|
70
79
|
*/
|
|
71
80
|
export async function loadArchive(projectRoot) {
|
|
72
81
|
// R17-D: .kanban の realpath 信頼境界を確立 (.kanban 自体が symlink / 不在なら [] に倒す)
|
|
@@ -84,12 +93,33 @@ export async function loadArchive(projectRoot) {
|
|
|
84
93
|
const files = await collectKanbanFilesNoSymlink(archiveDir, kanbanReal);
|
|
85
94
|
const result = [];
|
|
86
95
|
for (const abs of files) {
|
|
96
|
+
let parsed;
|
|
87
97
|
try {
|
|
88
98
|
const raw = await readKanbanFile(abs, kanbanReal);
|
|
89
|
-
|
|
99
|
+
parsed = parseTaskFile(raw);
|
|
90
100
|
}
|
|
91
101
|
catch {
|
|
92
|
-
//
|
|
102
|
+
// 本文すら取り出せない (YAML 構文エラー等) / symlink はスキップ
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const id = path.basename(abs).slice(0, -3);
|
|
106
|
+
const month = extractArchiveMonth(abs, kanbanReal);
|
|
107
|
+
// frontmatter が検証を通れば表示用フィールドを付ける。通らなければ id + body のみ (壊れタスク許容)
|
|
108
|
+
const fm = taskFrontmatterSchema.safeParse(parsed.data);
|
|
109
|
+
if (fm.success) {
|
|
110
|
+
result.push({
|
|
111
|
+
id,
|
|
112
|
+
body: parsed.body,
|
|
113
|
+
month,
|
|
114
|
+
title: fm.data.title,
|
|
115
|
+
status: fm.data.status,
|
|
116
|
+
assignee: fm.data.assignee,
|
|
117
|
+
labels: fm.data.labels,
|
|
118
|
+
updated: fm.data.updated,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
result.push({ id, body: parsed.body, month });
|
|
93
123
|
}
|
|
94
124
|
}
|
|
95
125
|
return result;
|