aizuchi 0.3.0 → 0.5.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 +65 -1
- package/README.md +4 -3
- package/dist/bin/aizuchi.js +160 -13
- package/dist/server/app.js +94 -2
- package/dist/server/lib/docs.js +175 -2
- package/dist/server/lib/related-docs-validate.js +37 -0
- package/dist/server/lib/task-ops.js +1 -0
- package/dist/shared/ai-health.js +78 -0
- package/dist/shared/schema.js +11 -0
- package/dist/shared/task-setters.js +132 -0
- package/dist/web/assets/index-C3mZwbO4.js +103 -0
- package/dist/web/assets/index-QsPhBKSP.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B4RPR1mO.css +0 -1
- package/dist/web/assets/index-DG0paXSh.js +0 -93
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-06-17
|
|
9
|
+
|
|
10
|
+
資料 (ドキュメント) をボード上で作って育てられるようにし、依存・優先度のその場編集や AI 参加者の状態把握を加えたリリース。
|
|
11
|
+
|
|
12
|
+
### Added (追加)
|
|
13
|
+
|
|
14
|
+
- **関連資料の作成・編集・削除**: これまで読むだけだった「関連資料」を、ボード上で新規作成・編集・削除できるように。
|
|
15
|
+
保存先は新しい設定 `docsWriteDirs` に列挙したディレクトリのみ (既定は空 = 機能オフ・読取専用の `docsDirs` と分離)。
|
|
16
|
+
保存はアトミック (一時ファイル→rename)、上書きは保存時刻 (mtime) 比較で競合を検知、削除は `.kanban/.trash` へ退避して
|
|
17
|
+
から消す (復元可能)。保存ダイアログで親フォルダ選択 + 新規フォルダ作成 + ファイル名指定。資料から直接タスクを起票する
|
|
18
|
+
導線付き。パスは正規化 + symlink 解決後にプロジェクト配下のみ許可 (パストラバーサル対策)。
|
|
19
|
+
- **資料エディタのシンタックスハイライト**: Markdown を編集中、見出し・太字・コード・リスト・リンクなどが色分け表示
|
|
20
|
+
されるように。依存ライブラリを足さない自前実装で、ハイライトは本文を一切書き換えない。
|
|
21
|
+
- **資料エディタの横並び表示**: 編集とプレビューをタブ切替だけでなく **横に並べて同時に** 見られるように (既定)。
|
|
22
|
+
入力に追従するライブプレビュー付き。画面が狭いときは自動で縦積みに切り替わる。全幅の編集 / プレビューも選べる。
|
|
23
|
+
- **依存・優先度のその場編集**: タスク詳細の「メタ」タブで depends_on (依存) と priority をクリック操作で編集できるように。
|
|
24
|
+
クリティカルパスを見ながらその場で依存関係や優先度を組み替えられる。保存は既存の競合検知に乗る。
|
|
25
|
+
- **AI オンボーディング診断パネル**: 参加している AI メンバーごとに、タスクの書式ミス・未登録メンバーの使用・設定の問題を
|
|
26
|
+
「直し方」付きで一覧表示する読取専用パネル。AI が規約どおりに参加できているかを人間が把握できる。
|
|
27
|
+
- **設定画面でのディレクトリ編集**: 資料の読取先 (`docsDirs`) と書込先 (`docsWriteDirs`) を設定画面の GUI から
|
|
28
|
+
追加・削除できるように。これまで config.json を手で編集する必要があった資料機能の有効化が、画面から行える。
|
|
29
|
+
- キーボードショートカットのヘルプ (`?`) に **「AI への導入指示をコピー」ボタン**を追加。AI エージェント
|
|
30
|
+
(Claude 等) にこのプロジェクトへ AIzuchi 経由で参加してもらうための指示文を、プロジェクト名・AI メンバー ID・
|
|
31
|
+
起票モード (free / proposal) を反映してワンクリックでコピーできる。新規プロジェクトのオンボーディングが速くなる。
|
|
32
|
+
- **`serve` のポート自動フォールバック**: 起点ポート (既定 `5151`) が使用中のとき、自動で次の空きポート
|
|
33
|
+
(`5152`, `5153`…) を探して起動するように。複数プロジェクトを同時に `serve` してもポート衝突で止まらない。
|
|
34
|
+
起点と違うポートで起動した場合はその旨をログに出す。起点は毎回 `5151` から試すため、次回起動時に空いていれば
|
|
35
|
+
`5151` に戻る (状態は保存しない)。`--port` を明示した場合のみ従来どおり固定 (使用中ならエラー終了)。
|
|
36
|
+
バインド先は `127.0.0.1` 固定のまま変えない (N-1)。
|
|
37
|
+
|
|
38
|
+
### Changed (変更)
|
|
39
|
+
|
|
40
|
+
- マイルストーンの「対応履歴」の行で、タイトル列を固定幅にして**本文 (サマリ) の開始位置を全行で揃える**ように。
|
|
41
|
+
これまではタイトルの文字数ぶんだけ幅が変わり本文の左端がバラついていた。収まらないタイトルは `…` で省略し、
|
|
42
|
+
全文は hover (title 属性) とクリックで確認できる。マイルストーン所属タスク一覧 (status を出す行) は対象外。
|
|
43
|
+
- アクセシビリティ改善: いくつかの入力欄 (コメント投稿者・コメント入力・メンバー種別・詳細 JSON エディタ) に
|
|
44
|
+
適切なラベル (aria-label) を付与し、ブラウザ開発者ツールのフォーム警告を解消。
|
|
45
|
+
|
|
46
|
+
## [0.4.0] - 2026-06-16
|
|
47
|
+
|
|
48
|
+
「今どうなっているか・次に何をすべきか」の見通しと、堅牢性・操作性を高めるリリース。
|
|
49
|
+
|
|
50
|
+
### Added (追加)
|
|
51
|
+
|
|
52
|
+
- **ホーム「次の一手」ビュー**(既定の最初のタブ): 判断待ち・停滞・クリティカルパスの律速・WIP 超過・新着を
|
|
53
|
+
1 画面に集約し、開いた瞬間に「今プロジェクトを止めているもの」「次に着手すべきタスク」が分かる。
|
|
54
|
+
既存の集計ロジックを束ねるだけで、新しい保存先や通信は増やさない。タブの並べ替え・非表示で従来どおり
|
|
55
|
+
ボードを最初のタブに戻せる。
|
|
56
|
+
- **タスク詳細のタイムラインタブ**: 作業ログ・やりとり・コミットを 1 本の時系列にまとめて表示し、
|
|
57
|
+
「このタスクで何がいつ起きたか」を一目で追える。
|
|
58
|
+
- **やりとり / 作業ログ / タイムラインの `@member` ハイライト**: 登録メンバー宛のメンションを強調表示。
|
|
59
|
+
やりとりタブでは投稿者(=あなた)宛のメンションをさらに強調する。
|
|
60
|
+
- **AI 用 CLI に読取系コマンドを追加**(read-only): `aizuchi task list`(一覧・`--status` / `--assignee` 絞り込み)、
|
|
61
|
+
`task show <id>`(1 タスクの詳細)、`task next`(依存が解決済みで着手できるタスクをクリティカルパス優先で提示)。
|
|
62
|
+
AI が「今の状態」「次の一手」をファイル経由で引ける。いずれも `--json` 対応。
|
|
63
|
+
- **キーボードショートカットのヘルプ**: `?` キーまたはヘッダーの「?」ボタンで、⌘K・カードの移動(Space で掴む /
|
|
64
|
+
矢印で移動)・Enter で詳細・Esc などの一覧を表示。
|
|
65
|
+
|
|
66
|
+
### Changed (変更)
|
|
67
|
+
|
|
68
|
+
- 各ビューを Error Boundary で隔離し、1 つのビューの表示エラーがアプリ全体を白画面にしないように
|
|
69
|
+
(他のタブへ切り替える / 再試行で復帰できる)。
|
|
70
|
+
|
|
8
71
|
## [0.3.0] - 2026-06-16
|
|
9
72
|
|
|
10
73
|
UI を大幅に刷新し、協働ループ・自己診断・グローバル設定編集などを追加した節目のリリース。
|
|
@@ -115,7 +178,8 @@ UI をデザイントークン体系で刷新しました。
|
|
|
115
178
|
- **セキュリティ**: ローカル限定 (127.0.0.1)・書込パス許可リスト + symlink / トラバーサル拒否・
|
|
116
179
|
アトミック書込 + mtime 競合検知・秘匿情報のログ非出力・git 履歴連携 (read-only)。
|
|
117
180
|
|
|
118
|
-
[Unreleased]: https://github.com/megaphone-tokyo/AIzuchi/compare/v0.
|
|
181
|
+
[Unreleased]: https://github.com/megaphone-tokyo/AIzuchi/compare/v0.4.0...HEAD
|
|
182
|
+
[0.4.0]: https://github.com/megaphone-tokyo/AIzuchi/compare/v0.3.0...v0.4.0
|
|
119
183
|
[0.3.0]: https://github.com/megaphone-tokyo/AIzuchi/compare/v0.2.0...v0.3.0
|
|
120
184
|
[0.2.0]: https://github.com/megaphone-tokyo/AIzuchi/compare/v0.1.0...v0.2.0
|
|
121
185
|
[0.1.0]: https://github.com/megaphone-tokyo/AIzuchi/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ UI 表示言語は英語 / 日本語(en / ja・既定 en)を切り替えら
|
|
|
88
88
|
マーカー区切りで追記。AI は専用 API なしでファイルを読み書きするだけで参加できます。
|
|
89
89
|
- **提案ワークフロー**: `aiTaskCreation` が `proposal` のとき、AI 起票は `proposed` で入り、
|
|
90
90
|
承認 / 却下を経て `todo` へ。モード違反は警告で検知。
|
|
91
|
-
- **AI 用 CLI(必須でない安全弁)**: `aizuchi task add / move <id> <status> / done <id> /
|
|
92
|
-
block <id> --type --reason / log <id> "msg" / comment <id> "msg" / revise <id> --reason`
|
|
93
|
-
|
|
91
|
+
- **AI 用 CLI(必須でない安全弁)**: 書込系 `aizuchi task add / move <id> <status> / done <id> /
|
|
92
|
+
block <id> --type --reason / log <id> "msg" / comment <id> "msg" / revise <id> --reason` と、
|
|
93
|
+
読取系 `list [--status S] [--assignee M] / show <id> / next`(着手可能なタスクを依存解決順に提示)を提供。
|
|
94
|
+
いずれも既存のスキーマ検証・採番直列化・行置換検証を再利用します(生ファイル編集も従来どおり可・`--json` 対応)。
|
|
94
95
|
|
|
95
96
|
### そのほか
|
|
96
97
|
|
package/dist/bin/aizuchi.js
CHANGED
|
@@ -4,9 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { serve } from '@hono/node-server';
|
|
7
|
-
import { DEFAULT_OVERVIEW_PORT, DEFAULT_PORT } from '../shared/schema.js';
|
|
7
|
+
import { DEFAULT_OVERVIEW_PORT, DEFAULT_PORT, PORT_FALLBACK_ATTEMPTS } from '../shared/schema.js';
|
|
8
|
+
import { analyzeCriticalPath } from '../shared/critical-path.js';
|
|
8
9
|
import { createApp } from '../server/app.js';
|
|
9
|
-
import { loadConfig } from '../server/board.js';
|
|
10
|
+
import { loadArchive, loadBoard, loadConfig } from '../server/board.js';
|
|
10
11
|
import { createEventBus } from '../server/events.js';
|
|
11
12
|
import { cleanupStaleTmpFiles, resolveKanbanReal, writeFileAtomic } from '../server/lib/fs-safe.js';
|
|
12
13
|
import { createOverviewApp } from '../server/overview.js';
|
|
@@ -15,7 +16,7 @@ import { startWatcher } from '../server/watcher.js';
|
|
|
15
16
|
/**
|
|
16
17
|
* AIzuchi CLI (要件 §4.3)。
|
|
17
18
|
* - init : .kanban/ を生成し、CLAUDE.md へ運用規約をマーカー区切りで追記する (N-9: 冪等)
|
|
18
|
-
* - serve: 127.0.0.1 限定でボードを起動する (--port > config > 5151)
|
|
19
|
+
* - serve: 127.0.0.1 限定でボードを起動する (--port > config > 5151。--port 未指定なら使用中時に次の空きへ自動フォールバック)
|
|
19
20
|
* - overview: 複数 .kanban の read-only 集約を起動する (--port > 5152、波 VII)
|
|
20
21
|
*/
|
|
21
22
|
export const MARKER_BEGIN = '<!-- aizuchi:begin -->';
|
|
@@ -158,28 +159,65 @@ export async function runServe(options) {
|
|
|
158
159
|
// .kanban が symlink / 不在: cleanup を skip (containment 外の削除はしない)
|
|
159
160
|
}
|
|
160
161
|
const { config } = await loadConfig(cwd);
|
|
161
|
-
|
|
162
|
+
// --port を明示したらそのポートに固定 (使用中ならエラー終了)。明示しない場合は
|
|
163
|
+
// config.port → 既定 5151 を起点に、使用中なら順に次の空きへフォールバックする
|
|
164
|
+
// (複数プロジェクトを同時に serve してもポート衝突で止まらないように)。
|
|
165
|
+
// 起点は毎回同じで状態を保存しないため、次回起動時に起点が空いていれば必ず起点で起動する。
|
|
166
|
+
const explicitPort = options.port !== undefined;
|
|
167
|
+
const basePort = options.port ?? config.port ?? DEFAULT_PORT;
|
|
168
|
+
const maxAttempts = explicitPort ? 1 : PORT_FALLBACK_ATTEMPTS;
|
|
162
169
|
const bus = createEventBus();
|
|
163
170
|
const { app } = createApp({ projectRoot: cwd, bus });
|
|
164
171
|
attachStaticAssets(app, path.join(packageRoot(), 'dist', 'web'));
|
|
165
172
|
const watcher = startWatcher({ kanbanDir, bus });
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
// 1 ポートぶんの listen 試行。成功で {server, boundPort}、EADDRINUSE で 'busy'、その他は reject。
|
|
174
|
+
// 127.0.0.1 限定でバインドする (N-1)。フォールバックで変えるのはポート番号のみ・host は固定。
|
|
175
|
+
const tryListen = (candidate) => new Promise((resolve, reject) => {
|
|
176
|
+
let settled = false;
|
|
168
177
|
// info.port は実際にバインドされたポート (port=0 のときは OS が選んだ番号が入る)
|
|
169
|
-
const created = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) =>
|
|
178
|
+
const created = serve({ fetch: app.fetch, port: candidate, hostname: '127.0.0.1' }, (info) => {
|
|
179
|
+
settled = true;
|
|
180
|
+
resolve({ server: created, boundPort: info.port });
|
|
181
|
+
});
|
|
170
182
|
created.on('error', (error) => {
|
|
183
|
+
if (settled)
|
|
184
|
+
return; // listen 成功後の実行時エラーはここでは扱わない
|
|
171
185
|
if (error.code === 'EADDRINUSE') {
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
try {
|
|
187
|
+
created.close();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* バインド失敗した server の close 失敗は無視 */
|
|
191
|
+
}
|
|
192
|
+
resolve('busy');
|
|
174
193
|
}
|
|
175
194
|
else {
|
|
176
195
|
reject(error);
|
|
177
196
|
}
|
|
178
197
|
});
|
|
179
|
-
}).catch(async (error) => {
|
|
180
|
-
await watcher.close();
|
|
181
|
-
throw error;
|
|
182
198
|
});
|
|
199
|
+
let listened = null;
|
|
200
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
201
|
+
const result = await tryListen(basePort + i).catch(async (error) => {
|
|
202
|
+
await watcher.close();
|
|
203
|
+
throw error; // EADDRINUSE 以外 (権限不足など) は即終了
|
|
204
|
+
});
|
|
205
|
+
if (result !== 'busy') {
|
|
206
|
+
listened = result;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (listened === null) {
|
|
211
|
+
await watcher.close();
|
|
212
|
+
if (explicitPort) {
|
|
213
|
+
throw new Error(`ポート ${basePort} は使用中です。別の --port <番号> を指定するか、起動中の AIzuchi を終了してください`);
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`空きポートが見つかりませんでした (${basePort}〜${basePort + maxAttempts - 1} はすべて使用中)。--port <番号> で指定してください`);
|
|
216
|
+
}
|
|
217
|
+
const { server, boundPort } = listened;
|
|
218
|
+
if (!explicitPort && boundPort !== basePort) {
|
|
219
|
+
log(`ポート ${basePort} は使用中のため ${boundPort} で起動しました`);
|
|
220
|
+
}
|
|
183
221
|
log(`AIzuchi: http://127.0.0.1:${boundPort} (project: ${config.projectName})`);
|
|
184
222
|
log('終了するには Ctrl+C');
|
|
185
223
|
return {
|
|
@@ -473,8 +511,117 @@ export async function runTaskCli(argv, io = {
|
|
|
473
511
|
const view = await ops.appendComment(positional[0] ?? '', opts.get('member') ?? 'claude', positional[1] ?? '');
|
|
474
512
|
return emit(view, `コメント追記: ${view.frontmatter.id}`);
|
|
475
513
|
}
|
|
514
|
+
// --- 読取系 (read-only。AI が「今の状態」「次の一手」をファイル経由で引ける) ---
|
|
515
|
+
case 'list': {
|
|
516
|
+
// アクティブなタスク一覧。--status / --assignee で絞り込み。書込なし
|
|
517
|
+
const board = await loadBoard(root);
|
|
518
|
+
const statusFilter = opts.get('status');
|
|
519
|
+
const assigneeFilter = opts.get('assignee');
|
|
520
|
+
const rows = board.tasks
|
|
521
|
+
.filter((t) => statusFilter === undefined || t.frontmatter.status === statusFilter)
|
|
522
|
+
.filter((t) => assigneeFilter === undefined || t.frontmatter.assignee === assigneeFilter)
|
|
523
|
+
.map((t) => ({
|
|
524
|
+
id: t.frontmatter.id,
|
|
525
|
+
status: t.frontmatter.status,
|
|
526
|
+
assignee: t.frontmatter.assignee,
|
|
527
|
+
priority: t.frontmatter.priority,
|
|
528
|
+
title: t.frontmatter.title,
|
|
529
|
+
labels: t.frontmatter.labels,
|
|
530
|
+
}))
|
|
531
|
+
.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
532
|
+
if (json) {
|
|
533
|
+
io.out(JSON.stringify(rows));
|
|
534
|
+
}
|
|
535
|
+
else if (rows.length === 0) {
|
|
536
|
+
io.out('(該当タスクなし)');
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
for (const r of rows)
|
|
540
|
+
io.out(`${r.id}\t${r.status}\t${r.assignee ?? '-'}\t${r.title}`);
|
|
541
|
+
}
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
case 'show': {
|
|
545
|
+
// 1 タスクの詳細 (frontmatter 要約 + 本文)。アクティブ → 無ければ archive を探す
|
|
546
|
+
const id = positional[0] ?? '';
|
|
547
|
+
if (id === '') {
|
|
548
|
+
io.err(json ? JSON.stringify({ error: 'id required', code: 'invalid_input' }) : 'show には <id> が必要です');
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
const board = await loadBoard(root);
|
|
552
|
+
const found = board.tasks.find((t) => t.frontmatter.id === id);
|
|
553
|
+
if (found !== undefined) {
|
|
554
|
+
if (json) {
|
|
555
|
+
io.out(JSON.stringify(found));
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
const f = found.frontmatter;
|
|
559
|
+
io.out(`${f.id} [${f.status}] ${f.title}`);
|
|
560
|
+
io.out(`assignee: ${f.assignee ?? '-'} priority: ${f.priority} labels: ${f.labels.join(', ') || '-'}`);
|
|
561
|
+
if (f.depends_on.length > 0)
|
|
562
|
+
io.out(`depends_on: ${f.depends_on.join(', ')}`);
|
|
563
|
+
if (f.status === 'pending')
|
|
564
|
+
io.out(`blocked: ${f.blocked_type ?? '-'} — ${f.blocked_reason ?? '-'}`);
|
|
565
|
+
io.out('');
|
|
566
|
+
io.out(found.body.trim());
|
|
567
|
+
}
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
const archived = (await loadArchive(root)).find((a) => a.id === id);
|
|
571
|
+
if (archived !== undefined) {
|
|
572
|
+
if (json) {
|
|
573
|
+
io.out(JSON.stringify({ ...archived, archived: true }));
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
io.out(`${archived.id} [archived]${archived.title ? ` ${archived.title}` : ''}`);
|
|
577
|
+
io.out('');
|
|
578
|
+
io.out((archived.body ?? '').trim());
|
|
579
|
+
}
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
io.err(json ? JSON.stringify({ error: 'not found', code: 'not_found', id }) : `タスクが見つかりません: ${id}`);
|
|
583
|
+
return 2;
|
|
584
|
+
}
|
|
585
|
+
case 'next': {
|
|
586
|
+
// 「次に着手できるタスク」= 未完了かつ依存が全て解決済み (active な先行依存なし) の
|
|
587
|
+
// backlog/todo/in_progress。クリティカルパス上を優先表示する。書込なし
|
|
588
|
+
const board = await loadBoard(root);
|
|
589
|
+
const active = board.tasks.filter((t) => t.frontmatter.status !== 'done');
|
|
590
|
+
const cp = analyzeCriticalPath(active.map((t) => ({ id: t.frontmatter.id, dependsOn: t.frontmatter.depends_on })));
|
|
591
|
+
const onCp = new Set(cp.criticalPath);
|
|
592
|
+
const activeIds = new Set(active.map((t) => t.frontmatter.id));
|
|
593
|
+
const ACTIONABLE = new Set(['backlog', 'todo', 'in_progress']);
|
|
594
|
+
const ready = active
|
|
595
|
+
.filter((t) => ACTIONABLE.has(t.frontmatter.status) &&
|
|
596
|
+
t.frontmatter.depends_on.every((d) => !activeIds.has(d)))
|
|
597
|
+
.map((t) => ({
|
|
598
|
+
id: t.frontmatter.id,
|
|
599
|
+
status: t.frontmatter.status,
|
|
600
|
+
title: t.frontmatter.title,
|
|
601
|
+
onCriticalPath: onCp.has(t.frontmatter.id),
|
|
602
|
+
}))
|
|
603
|
+
.sort((a, b) => {
|
|
604
|
+
if (a.onCriticalPath !== b.onCriticalPath)
|
|
605
|
+
return a.onCriticalPath ? -1 : 1;
|
|
606
|
+
return a.id.localeCompare(b.id, 'en', { numeric: true });
|
|
607
|
+
});
|
|
608
|
+
if (json) {
|
|
609
|
+
io.out(JSON.stringify({ ready, criticalPath: cp.criticalPath, hasCycle: cp.cycleNodes.length > 0 }));
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
if (cp.cycleNodes.length > 0)
|
|
613
|
+
io.out(`⚠ 依存に循環があります: ${cp.cycleNodes.join(', ')}`);
|
|
614
|
+
if (ready.length === 0) {
|
|
615
|
+
io.out('着手可能なタスクはありません (全て完了 / 依存待ち / ブロック中)');
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
io.out('次に着手できるタスク (依存解決済み):');
|
|
619
|
+
for (const r of ready)
|
|
620
|
+
io.out(`- ${r.id} (${r.status})${r.onCriticalPath ? ' ★CP' : ''} ${r.title}`);
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
476
623
|
default:
|
|
477
|
-
io.err('task サブコマンド: add / move <id> <status> / done <id> / block <id> --type --reason / revise <id> [--reason "..."] / log <id> "msg" / comment <id> "msg"');
|
|
624
|
+
io.err('task サブコマンド: add / move <id> <status> / done <id> / block <id> --type --reason / revise <id> [--reason "..."] / log <id> "msg" / comment <id> "msg" / list [--status S] [--assignee M] / show <id> / next');
|
|
478
625
|
return 1;
|
|
479
626
|
}
|
|
480
627
|
}
|
package/dist/server/app.js
CHANGED
|
@@ -10,7 +10,8 @@ import { loadArchive, loadBoard, loadConfig, loadTaskView } from './board.js';
|
|
|
10
10
|
import { createEventBus } from './events.js';
|
|
11
11
|
import { allocateNextId, createSerialQueue } from './lib/id.js';
|
|
12
12
|
import { assertDirInsideKanban, collectKanbanFilesNoSymlink, FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile, statKanbanFile, } from './lib/fs-safe.js';
|
|
13
|
-
import { buildDocsTree, readDoc } from './lib/docs.js';
|
|
13
|
+
import { buildDocsTree, buildDocsWriteTree, deleteDoc, readDoc, writeDoc } from './lib/docs.js';
|
|
14
|
+
import { assertRelatedDocsAllowed } from './lib/related-docs-validate.js';
|
|
14
15
|
import { findCommitsForTask } from './lib/git.js';
|
|
15
16
|
import { buildCatalog, buildFileTree, buildGlobalCatalog, fileMtime, globalFileMtime, readConfigFile, readGlobalConfigFile, saveConfigFile, saveGlobalConfigFile, } from './lib/files.js';
|
|
16
17
|
import { applyPinpointPatch, renameTaskId, replaceDependsOnId, replaceMemberKeys, verifyMemberRename, verifyPinpointResult, verifyRename, } from './lib/pinpoint.js';
|
|
@@ -75,7 +76,7 @@ export function applySecurityMiddleware(app) {
|
|
|
75
76
|
// GET は読出専用 (CORS ヘッダ無しでクロスオリジン読出不可) なので対象外。
|
|
76
77
|
app.use('/api/*', async (c, next) => {
|
|
77
78
|
const method = c.req.method;
|
|
78
|
-
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
79
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') {
|
|
79
80
|
const contentType = c.req.header('content-type') ?? '';
|
|
80
81
|
// "application/json" / "application/json; charset=utf-8" を許可。それ以外は 415
|
|
81
82
|
const mediaType = contentType.split(';')[0].trim().toLowerCase();
|
|
@@ -223,6 +224,17 @@ export function createApp(options) {
|
|
|
223
224
|
if (violations.length > 0) {
|
|
224
225
|
return c.json({ error: violations.join(' / ') }, 400);
|
|
225
226
|
}
|
|
227
|
+
// 波 VIII: related_docs は docsDirs / docsWriteDirs 配下の妥当な .md 相対パスのみ受け付ける
|
|
228
|
+
// (frontmatter に保存して良い参照値かの検証。読取時の symlink/TOCTOU 防御は docs.ts が担保)
|
|
229
|
+
if (input.data.related_docs !== undefined && input.data.related_docs.length > 0) {
|
|
230
|
+
try {
|
|
231
|
+
assertRelatedDocsAllowed(projectRoot, config, input.data.related_docs);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const message = error instanceof PathValidationError ? error.message : 'related_docs が不正です';
|
|
235
|
+
return c.json({ error: message }, 400);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
226
238
|
// 走査 → 採番 → 書込 をアトミックに直列化する (同時起票の ID 重複防止)。
|
|
227
239
|
// 書込は排他作成 (exclusive) にし、採番後の窓で AI が同 ID を直書きしていた場合は
|
|
228
240
|
// 上書きせず再採番 retry する (N-3: POST が外部データを silent 上書きするのを防ぐ)
|
|
@@ -239,6 +251,7 @@ export function createApp(options) {
|
|
|
239
251
|
created_by: input.data.created_by,
|
|
240
252
|
priority: input.data.priority ?? 'medium',
|
|
241
253
|
labels: input.data.labels ?? [],
|
|
254
|
+
related_docs: input.data.related_docs,
|
|
242
255
|
blocked_type: null,
|
|
243
256
|
blocked_reason: null,
|
|
244
257
|
created: now,
|
|
@@ -1120,6 +1133,85 @@ export function createApp(options) {
|
|
|
1120
1133
|
throw error;
|
|
1121
1134
|
}
|
|
1122
1135
|
});
|
|
1136
|
+
// GET /api/docs/write-tree — docsWriteDirs 配下の .md ツリー (保存先フォルダ選択 UI 用・symlink 非追従)
|
|
1137
|
+
app.get('/api/docs/write-tree', async (c) => {
|
|
1138
|
+
const { config } = await loadConfig(projectRoot);
|
|
1139
|
+
return c.json({ trees: await buildDocsWriteTree(projectRoot, config) });
|
|
1140
|
+
});
|
|
1141
|
+
// POST /api/docs — 資料を新規作成 (exclusive)。親フォルダ未作成なら作成する。既存は 409。
|
|
1142
|
+
app.post('/api/docs', async (c) => {
|
|
1143
|
+
const body = (await c.req.json().catch(() => null));
|
|
1144
|
+
if (body === null || typeof body.path !== 'string' || body.path === '' || typeof body.content !== 'string') {
|
|
1145
|
+
return c.json({ error: 'path (文字列) と content (文字列) が必要です' }, 400);
|
|
1146
|
+
}
|
|
1147
|
+
const relPath = body.path;
|
|
1148
|
+
const content = body.content;
|
|
1149
|
+
const { config } = await loadConfig(projectRoot);
|
|
1150
|
+
return enqueue(async () => {
|
|
1151
|
+
try {
|
|
1152
|
+
const result = await writeDoc(projectRoot, config, relPath, content, 'create');
|
|
1153
|
+
return c.json(result, 201);
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
if (error instanceof FileExistsError)
|
|
1157
|
+
return c.json({ error: '同じパスの資料が既に存在します' }, 409);
|
|
1158
|
+
if (error instanceof PathValidationError)
|
|
1159
|
+
return c.json({ error: error.message }, 403);
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
// PUT /api/docs?path=... — 資料を上書き保存 (mtime 厳密比較で競合検知)。範囲外 403 / drift 409。
|
|
1165
|
+
app.put('/api/docs', async (c) => {
|
|
1166
|
+
const relPath = c.req.query('path');
|
|
1167
|
+
if (relPath === undefined || relPath === '') {
|
|
1168
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
1169
|
+
}
|
|
1170
|
+
const body = (await c.req.json().catch(() => null));
|
|
1171
|
+
if (body === null || typeof body.content !== 'string' || typeof body.baseMtimeMs !== 'number') {
|
|
1172
|
+
return c.json({ error: 'content (文字列) と baseMtimeMs (数値) が必要です' }, 400);
|
|
1173
|
+
}
|
|
1174
|
+
const content = body.content;
|
|
1175
|
+
const baseMtimeMs = body.baseMtimeMs;
|
|
1176
|
+
const { config } = await loadConfig(projectRoot);
|
|
1177
|
+
return enqueue(async () => {
|
|
1178
|
+
try {
|
|
1179
|
+
const result = await writeDoc(projectRoot, config, relPath, content, 'overwrite', baseMtimeMs);
|
|
1180
|
+
if ('conflict' in result) {
|
|
1181
|
+
return c.json({ error: '外部で変更されています', serverContent: result.conflict.serverContent, serverMtimeMs: result.conflict.serverMtimeMs }, 409);
|
|
1182
|
+
}
|
|
1183
|
+
return c.json(result);
|
|
1184
|
+
}
|
|
1185
|
+
catch (error) {
|
|
1186
|
+
if (error instanceof PathValidationError)
|
|
1187
|
+
return c.json({ error: error.message }, 403);
|
|
1188
|
+
if (error.code === 'ENOENT')
|
|
1189
|
+
return c.json({ error: '資料が見つかりません' }, 404);
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
// DELETE /api/docs?path=... — 資料を削除 (unlink 前に .kanban/.trash へ退避)。不在 404 / 範囲外 403。
|
|
1195
|
+
app.delete('/api/docs', async (c) => {
|
|
1196
|
+
const relPath = c.req.query('path');
|
|
1197
|
+
if (relPath === undefined || relPath === '') {
|
|
1198
|
+
return c.json({ error: 'path クエリが必要です' }, 400);
|
|
1199
|
+
}
|
|
1200
|
+
const { config } = await loadConfig(projectRoot);
|
|
1201
|
+
return enqueue(async () => {
|
|
1202
|
+
try {
|
|
1203
|
+
const result = await deleteDoc(projectRoot, config, relPath);
|
|
1204
|
+
return c.json({ path: result.path, deleted: true });
|
|
1205
|
+
}
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
if (error instanceof PathValidationError)
|
|
1208
|
+
return c.json({ error: error.message }, 403);
|
|
1209
|
+
if (error.code === 'ENOENT')
|
|
1210
|
+
return c.json({ error: '資料が見つかりません' }, 404);
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1123
1215
|
// ---------------------------------------------------------------------
|
|
1124
1216
|
// GET /api/tasks/:id/commits — タスク ID を含む git コミット (S-1・read-only)
|
|
1125
1217
|
// ID をパスから取得 → taskIdSchema で検証 → git.ts が更に厳格バリデーション + execFile (シェル非経由)。
|
package/dist/server/lib/docs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
3
|
+
import { isSecretPath, isStrictlyInside, PathValidationError, readFileNoFollow, resolveKanbanReal, safeWriteFile, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
4
|
+
import { snapshotToTrash, DEFAULT_TRASH_KEEP } from './trash.js';
|
|
4
5
|
/**
|
|
5
6
|
* 関連資料 (波 VIII) の読取専用ファイル I/O。
|
|
6
7
|
* config.docsDirs に列挙されたディレクトリ配下の .md のみを対象にする。
|
|
@@ -23,6 +24,15 @@ function effectiveDocsDirs(root, config) {
|
|
|
23
24
|
const kanbanDir = path.join(root, '.kanban');
|
|
24
25
|
return sanitizeConfigDirEntries(root, config.docsDirs).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* config.docsWriteDirs を root 配下の有効な書込先ディレクトリ集合 (絶対パス) に正規化する。
|
|
29
|
+
* effectiveDocsDirs (読取) と同一ロジック: root 自身 / root 外 / .kanban 配下 / 秘匿パスを除外する
|
|
30
|
+
* (読取と書込で許可リストを分離するため別関数。docsWriteDirs 既定は空 = 書込オフ)。
|
|
31
|
+
*/
|
|
32
|
+
function effectiveDocsWriteDirs(root, config) {
|
|
33
|
+
const kanbanDir = path.join(root, '.kanban');
|
|
34
|
+
return sanitizeConfigDirEntries(root, config.docsWriteDirs).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
35
|
+
}
|
|
26
36
|
/** 要求された相対パスが docsDirs 配下の .md であることを検証し、正規化済み絶対パスを返す */
|
|
27
37
|
export async function resolveDocPath(projectRoot, config, requestedRelPath) {
|
|
28
38
|
const root = path.resolve(projectRoot);
|
|
@@ -87,11 +97,12 @@ export async function readDoc(projectRoot, config, relPath) {
|
|
|
87
97
|
// R14-4: resolveDocPath の lstat (symlink 拒否) と readFile の間で対象が symlink に差し替わる
|
|
88
98
|
// TOCTOU 窓を O_NOFOLLOW で塞ぐ (対象が symlink なら open が ELOOP で失敗する)。
|
|
89
99
|
const content = await readFileNoFollow(absPath);
|
|
100
|
+
const mtimeMs = (await fs.lstat(absPath)).mtimeMs;
|
|
90
101
|
// absPath は realpath ベースの canonical のため、相対化も realpath 済み root を基準にする
|
|
91
102
|
// (macOS の /var → /private/var のような root 側 symlink で相対パスがずれないように)
|
|
92
103
|
const root = path.resolve(projectRoot);
|
|
93
104
|
const rootReal = await fs.realpath(root).catch(() => root);
|
|
94
|
-
return { path: path.relative(rootReal, absPath), content };
|
|
105
|
+
return { path: path.relative(rootReal, absPath), content, mtimeMs };
|
|
95
106
|
}
|
|
96
107
|
/**
|
|
97
108
|
* docsDirs 配下の .md ファイルツリーを構築する。buildFileTree (files.ts) と同じ手動再帰で
|
|
@@ -152,3 +163,165 @@ async function buildDocsNode(root, absPath) {
|
|
|
152
163
|
}
|
|
153
164
|
return null;
|
|
154
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* target の祖先のうち realpath できる最も近いものと、未作成 segment 列を返す
|
|
168
|
+
* (config-paths.ts の同名非 export 関数と同型。docs 書込でも親未作成を許すために複製・LEARN#8b N=2 で defer)。
|
|
169
|
+
* 未作成 segment は実体が無く symlink になり得ないため、containment 判定は実在祖先の realpath で足りる。
|
|
170
|
+
*/
|
|
171
|
+
async function resolveNearestExistingAncestor(target) {
|
|
172
|
+
const missing = [];
|
|
173
|
+
let cur = target;
|
|
174
|
+
for (;;) {
|
|
175
|
+
try {
|
|
176
|
+
return { real: await fs.realpath(cur), missing };
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error.code !== 'ENOENT')
|
|
180
|
+
throw error;
|
|
181
|
+
const parent = path.dirname(cur);
|
|
182
|
+
if (parent === cur)
|
|
183
|
+
throw new PathValidationError('親ディレクトリが解決できません');
|
|
184
|
+
missing.unshift(path.basename(cur));
|
|
185
|
+
cur = parent;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 書込要求の相対パスが docsWriteDirs 配下の .md であることを検証し、正規化済み canonical と rootReal を返す
|
|
191
|
+
* (resolveDocPath の write 版)。読取と異なり「親ディレクトリが未作成」を許す (新規フォルダ作成 + ファイル新規作成のため)。
|
|
192
|
+
* 多段検査: .md 拡張子 → root 配下 → docsWriteDirs 接頭辞 → isSecretPath → 実在祖先 realpath containment
|
|
193
|
+
* → docsWriteDirs realpath 再確認 → 対象 symlink 拒否。
|
|
194
|
+
*/
|
|
195
|
+
export async function resolveDocWritePath(projectRoot, config, requestedRelPath) {
|
|
196
|
+
const root = path.resolve(projectRoot);
|
|
197
|
+
if (!requestedRelPath.endsWith('.md')) {
|
|
198
|
+
throw new PathValidationError('資料は .md ファイルのみ書き込めます');
|
|
199
|
+
}
|
|
200
|
+
const resolved = path.resolve(root, requestedRelPath);
|
|
201
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
202
|
+
throw new PathValidationError('プロジェクトルート外への書込は許可されていません');
|
|
203
|
+
}
|
|
204
|
+
const writeDirs = effectiveDocsWriteDirs(root, config);
|
|
205
|
+
if (!writeDirs.some((dir) => isStrictlyInside(dir, resolved))) {
|
|
206
|
+
throw new PathValidationError('docsWriteDirs 外への書込は許可されていません');
|
|
207
|
+
}
|
|
208
|
+
if (isSecretPath(resolved)) {
|
|
209
|
+
throw new PathValidationError('秘匿ファイルへの書込は許可されていません');
|
|
210
|
+
}
|
|
211
|
+
// 親 (や中間 dir) が未作成なら「最も近い実在祖先」で containment を判定する (config-paths.ts と同方式)。
|
|
212
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
213
|
+
const { real: ancestorReal, missing } = await resolveNearestExistingAncestor(path.dirname(resolved));
|
|
214
|
+
if (ancestorReal !== rootReal && !ancestorReal.startsWith(rootReal + path.sep)) {
|
|
215
|
+
throw new PathValidationError('symlink 経由でプロジェクトルート外を指しています');
|
|
216
|
+
}
|
|
217
|
+
const parentReal = missing.length > 0 ? path.join(ancestorReal, ...missing) : ancestorReal;
|
|
218
|
+
// docsWriteDirs realpath 解決後も配下か再確認する
|
|
219
|
+
const allowedReals = await Promise.all(writeDirs.map((dir) => fs.realpath(dir).catch(() => dir)));
|
|
220
|
+
const stillInside = allowedReals.some((dir) => parentReal === dir || parentReal.startsWith(dir + path.sep));
|
|
221
|
+
if (!stillInside) {
|
|
222
|
+
throw new PathValidationError('symlink 経由で docsWriteDirs 外を指しています');
|
|
223
|
+
}
|
|
224
|
+
const canonical = path.join(parentReal, path.basename(resolved));
|
|
225
|
+
if (isSecretPath(canonical)) {
|
|
226
|
+
throw new PathValidationError('秘匿ファイルへの書込は許可されていません');
|
|
227
|
+
}
|
|
228
|
+
// 対象が既存 symlink なら拒否 (symlink 越しの書込防止)。未存在は ENOENT で素通り (新規作成)。
|
|
229
|
+
try {
|
|
230
|
+
const lst = await fs.lstat(canonical);
|
|
231
|
+
if (lst.isSymbolicLink()) {
|
|
232
|
+
throw new PathValidationError('symlink への書込は許可されていません');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error instanceof PathValidationError)
|
|
237
|
+
throw error;
|
|
238
|
+
if (error.code !== 'ENOENT')
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
return { canonical, rootReal };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* canonical の親ディレクトリを作成する (新規フォルダ込みの保存用)。
|
|
245
|
+
* resolveDocWritePath が親の realpath containment を済ませた canonical を渡す前提なので、
|
|
246
|
+
* ここでは未作成 segment を recursive mkdir するだけ (containment 再検査は呼び出し前提条件)。
|
|
247
|
+
*/
|
|
248
|
+
async function ensureDir(canonical) {
|
|
249
|
+
await fs.mkdir(path.dirname(canonical), { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
export async function writeDoc(projectRoot, config, relPath, content, mode, baseMtimeMs) {
|
|
252
|
+
const { canonical, rootReal } = await resolveDocWritePath(projectRoot, config, relPath);
|
|
253
|
+
const writeDirs = effectiveDocsWriteDirs(path.resolve(projectRoot), config);
|
|
254
|
+
if (mode === 'overwrite') {
|
|
255
|
+
let serverContent = null;
|
|
256
|
+
let serverMtimeMs = null;
|
|
257
|
+
try {
|
|
258
|
+
serverContent = await readFileNoFollow(canonical);
|
|
259
|
+
serverMtimeMs = (await fs.lstat(canonical)).mtimeMs;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (error.code !== 'ENOENT')
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
if (serverMtimeMs === null) {
|
|
266
|
+
const err = new Error('資料が見つかりません');
|
|
267
|
+
err.code = 'ENOENT';
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
if (serverMtimeMs !== null && serverMtimeMs !== baseMtimeMs) {
|
|
271
|
+
return { conflict: { serverContent: serverContent ?? '', serverMtimeMs } };
|
|
272
|
+
}
|
|
273
|
+
// 上書き前に旧版を .kanban/.trash へ退避する (ベストエフォート安全網・deleteDoc と同方式)。
|
|
274
|
+
try {
|
|
275
|
+
const kanbanDir = path.join(rootReal, '.kanban');
|
|
276
|
+
const kanbanReal = await resolveKanbanReal(path.resolve(projectRoot));
|
|
277
|
+
const stem = path.basename(canonical).replace(/\.md$/i, '');
|
|
278
|
+
const trashId = `doc-${stem.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
279
|
+
await snapshotToTrash(trashId, serverContent ?? '', kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// 退避できなくても上書きは進める (安全網のベストエフォート)
|
|
283
|
+
}
|
|
284
|
+
await safeWriteFile(canonical, content, writeDirs);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
await ensureDir(canonical);
|
|
288
|
+
await safeWriteFile(canonical, content, writeDirs, { exclusive: true });
|
|
289
|
+
}
|
|
290
|
+
const mtimeMs = (await fs.lstat(canonical)).mtimeMs;
|
|
291
|
+
return { path: path.relative(rootReal, canonical), mtimeMs };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* docsWriteDirs 配下の資料を削除する。unlink の前に現内容を .kanban/.trash へコピー退避する (誤削除の安全網)。
|
|
295
|
+
* 退避はベストエフォート (失敗しても削除は進む)。対象不在は ENOENT を投げる (呼び出し側で 404)。
|
|
296
|
+
*/
|
|
297
|
+
export async function deleteDoc(projectRoot, config, relPath) {
|
|
298
|
+
const { canonical, rootReal } = await resolveDocWritePath(projectRoot, config, relPath);
|
|
299
|
+
// 現内容を読む (不在なら ENOENT → 呼び出し側 404)。symlink は resolveDocWritePath が既に拒否済み。
|
|
300
|
+
const content = await readFileNoFollow(canonical);
|
|
301
|
+
try {
|
|
302
|
+
const kanbanDir = path.join(rootReal, '.kanban');
|
|
303
|
+
const kanbanReal = await resolveKanbanReal(projectRoot);
|
|
304
|
+
const stem = path.basename(canonical).replace(/\.md$/i, '');
|
|
305
|
+
const trashId = `doc-${stem.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
306
|
+
await snapshotToTrash(trashId, content, kanbanDir, kanbanReal, config.trashKeep ?? DEFAULT_TRASH_KEEP);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// 退避できなくても削除は進める (安全網のベストエフォート)
|
|
310
|
+
}
|
|
311
|
+
await fs.rm(canonical, { force: false }); // 不在なら ENOENT (404)。symlink は事前拒否済み
|
|
312
|
+
return { path: path.relative(rootReal, canonical) };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* docsWriteDirs 配下の .md ファイルツリーを構築する (buildDocsTree の write 版・保存先選択 UI 用)。
|
|
316
|
+
* buildDocsNode を共有し symlink 非追従・秘匿除外・空 dir 除外は同一。
|
|
317
|
+
*/
|
|
318
|
+
export async function buildDocsWriteTree(projectRoot, config) {
|
|
319
|
+
const root = path.resolve(projectRoot);
|
|
320
|
+
const trees = [];
|
|
321
|
+
for (const absDir of effectiveDocsWriteDirs(root, config)) {
|
|
322
|
+
const node = await buildDocsNode(root, absDir);
|
|
323
|
+
if (node && node.type === 'dir')
|
|
324
|
+
trees.push(node);
|
|
325
|
+
}
|
|
326
|
+
return trees;
|
|
327
|
+
}
|