aizuchi 0.4.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 +38 -0
- package/dist/bin/aizuchi.js +48 -11
- 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-BYfu8Hdw.js +0 -93
- package/dist/web/assets/index-DiMXsTSP.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@
|
|
|
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
|
+
|
|
8
46
|
## [0.4.0] - 2026-06-16
|
|
9
47
|
|
|
10
48
|
「今どうなっているか・次に何をすべきか」の見通しと、堅牢性・操作性を高めるリリース。
|
package/dist/bin/aizuchi.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
8
|
import { analyzeCriticalPath } from '../shared/critical-path.js';
|
|
9
9
|
import { createApp } from '../server/app.js';
|
|
10
10
|
import { loadArchive, loadBoard, loadConfig } from '../server/board.js';
|
|
@@ -16,7 +16,7 @@ import { startWatcher } from '../server/watcher.js';
|
|
|
16
16
|
/**
|
|
17
17
|
* AIzuchi CLI (要件 §4.3)。
|
|
18
18
|
* - init : .kanban/ を生成し、CLAUDE.md へ運用規約をマーカー区切りで追記する (N-9: 冪等)
|
|
19
|
-
* - serve: 127.0.0.1 限定でボードを起動する (--port > config > 5151)
|
|
19
|
+
* - serve: 127.0.0.1 限定でボードを起動する (--port > config > 5151。--port 未指定なら使用中時に次の空きへ自動フォールバック)
|
|
20
20
|
* - overview: 複数 .kanban の read-only 集約を起動する (--port > 5152、波 VII)
|
|
21
21
|
*/
|
|
22
22
|
export const MARKER_BEGIN = '<!-- aizuchi:begin -->';
|
|
@@ -159,28 +159,65 @@ export async function runServe(options) {
|
|
|
159
159
|
// .kanban が symlink / 不在: cleanup を skip (containment 外の削除はしない)
|
|
160
160
|
}
|
|
161
161
|
const { config } = await loadConfig(cwd);
|
|
162
|
-
|
|
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;
|
|
163
169
|
const bus = createEventBus();
|
|
164
170
|
const { app } = createApp({ projectRoot: cwd, bus });
|
|
165
171
|
attachStaticAssets(app, path.join(packageRoot(), 'dist', 'web'));
|
|
166
172
|
const watcher = startWatcher({ kanbanDir, bus });
|
|
167
|
-
|
|
168
|
-
|
|
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;
|
|
169
177
|
// info.port は実際にバインドされたポート (port=0 のときは OS が選んだ番号が入る)
|
|
170
|
-
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
|
+
});
|
|
171
182
|
created.on('error', (error) => {
|
|
183
|
+
if (settled)
|
|
184
|
+
return; // listen 成功後の実行時エラーはここでは扱わない
|
|
172
185
|
if (error.code === 'EADDRINUSE') {
|
|
173
|
-
|
|
174
|
-
|
|
186
|
+
try {
|
|
187
|
+
created.close();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* バインド失敗した server の close 失敗は無視 */
|
|
191
|
+
}
|
|
192
|
+
resolve('busy');
|
|
175
193
|
}
|
|
176
194
|
else {
|
|
177
195
|
reject(error);
|
|
178
196
|
}
|
|
179
197
|
});
|
|
180
|
-
}).catch(async (error) => {
|
|
181
|
-
await watcher.close();
|
|
182
|
-
throw error;
|
|
183
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
|
+
}
|
|
184
221
|
log(`AIzuchi: http://127.0.0.1:${boundPort} (project: ${config.projectName})`);
|
|
185
222
|
log('終了するには Ctrl+C');
|
|
186
223
|
return {
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isSecretPath, isStrictlyInside, PathValidationError, sanitizeConfigDirEntries, } from './fs-safe.js';
|
|
3
|
+
/**
|
|
4
|
+
* 資料からの起票 (波 VIII) で frontmatter に保存して良い related_docs パスかを検証する純ロジック。
|
|
5
|
+
*
|
|
6
|
+
* - 対象ディレクトリ = config.docsDirs ∪ config.docsWriteDirs を sanitizeConfigDirEntries で
|
|
7
|
+
* root 配下に正規化し、.kanban 配下 / 秘匿パスを除外したもの (docs.ts の effectiveDocsDirs と同思想)。
|
|
8
|
+
* - 各要素は「.md 拡張子」「root 配下の相対パス (絶対 / .. を弾く)」「許可ディレクトリ配下」
|
|
9
|
+
* 「秘匿パスでない」を満たす必要がある。1 つでも違反すれば PathValidationError を投げる。
|
|
10
|
+
* - 同期判定のみ (fs.realpath は呼ばない): これは「読取」ではなく「frontmatter に書いて良い参照値か」の
|
|
11
|
+
* 妥当性チェック。symlink / TOCTOU 防御は読取時 (docs.ts resolveDocPath / readFileNoFollow) が担保する。
|
|
12
|
+
*/
|
|
13
|
+
export function assertRelatedDocsAllowed(projectRoot, config, docs) {
|
|
14
|
+
if (docs.length === 0)
|
|
15
|
+
return;
|
|
16
|
+
const root = path.resolve(projectRoot);
|
|
17
|
+
const kanbanDir = path.join(root, '.kanban');
|
|
18
|
+
const allowedDirs = sanitizeConfigDirEntries(root, [...config.docsDirs, ...config.docsWriteDirs]).filter((dir) => dir !== kanbanDir && !isStrictlyInside(kanbanDir, dir) && !isSecretPath(dir));
|
|
19
|
+
for (const rel of docs) {
|
|
20
|
+
if (!rel.endsWith('.md')) {
|
|
21
|
+
throw new PathValidationError('related_docs は .md ファイルのみ指定できます');
|
|
22
|
+
}
|
|
23
|
+
if (path.isAbsolute(rel)) {
|
|
24
|
+
throw new PathValidationError('related_docs に絶対パスは指定できません');
|
|
25
|
+
}
|
|
26
|
+
const resolved = path.resolve(root, rel);
|
|
27
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
28
|
+
throw new PathValidationError('related_docs にプロジェクトルート外のパスは指定できません');
|
|
29
|
+
}
|
|
30
|
+
if (isSecretPath(resolved)) {
|
|
31
|
+
throw new PathValidationError('related_docs に秘匿ファイルは指定できません');
|
|
32
|
+
}
|
|
33
|
+
if (!allowedDirs.some((dir) => isStrictlyInside(dir, resolved))) {
|
|
34
|
+
throw new PathValidationError('related_docs は docsDirs / docsWriteDirs 配下の資料のみ指定できます');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -100,6 +100,7 @@ export class TaskOps {
|
|
|
100
100
|
created_by: parsed.data.created_by,
|
|
101
101
|
priority: parsed.data.priority ?? 'medium',
|
|
102
102
|
labels: parsed.data.labels ?? [],
|
|
103
|
+
related_docs: parsed.data.related_docs,
|
|
103
104
|
blocked_type: null,
|
|
104
105
|
blocked_reason: null,
|
|
105
106
|
created: now,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI オンボーディング診断 (TASK-023)。
|
|
3
|
+
* board の warnings を AI メンバー別に集計し、各 warning kind に「直し方」の説明を付けた
|
|
4
|
+
* 構造を返す純関数。新データ源・新依存・永続化なし。
|
|
5
|
+
*
|
|
6
|
+
* now は引数注入 (Date.now ハードコード禁止・テスト再現性)。
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* warnings を AI メンバー別・kind 別に group-by する。
|
|
10
|
+
*
|
|
11
|
+
* - warning が特定の AI メンバーに紐付くか判定: taskId が null でない場合はタスクのパスから
|
|
12
|
+
* 直接判定できないため、メンバー ID を message 内から照合する (unregistered_member / mode_violation)。
|
|
13
|
+
* それ以外の kind (invalid_task / duplicate_id / invalid_config) は AI に特定紐付けできないため
|
|
14
|
+
* 全 AI メンバーに共通の「設定上の問題」として集約する。
|
|
15
|
+
* - assignee / created_by 別の精細な帰属は taskView へのアクセスが必要で pure fn では難しいため、
|
|
16
|
+
* 警告の message 文字列に含まれる member id を照合する簡易戦略を取る。
|
|
17
|
+
*
|
|
18
|
+
* @param warnings board.warnings (GET /api/board の warnings)
|
|
19
|
+
* @param members config.members (全メンバー)
|
|
20
|
+
*/
|
|
21
|
+
export function deriveAiHealth(warnings, members) {
|
|
22
|
+
const aiMembers = members.filter((m) => m.type === 'ai');
|
|
23
|
+
if (aiMembers.length === 0) {
|
|
24
|
+
return { members: [], noAiMembers: true };
|
|
25
|
+
}
|
|
26
|
+
const results = aiMembers.map((member) => {
|
|
27
|
+
// このメンバーに紐付く warning を収集する
|
|
28
|
+
const memberWarnings = warnings.filter((w) => isWarningForMember(w, member.id));
|
|
29
|
+
// kind 別にグループ化する
|
|
30
|
+
const kindMap = new Map();
|
|
31
|
+
for (const w of memberWarnings) {
|
|
32
|
+
const existing = kindMap.get(w.kind);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.push(w);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
kindMap.set(w.kind, [w]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const groups = Array.from(kindMap.entries()).map(([kind, ws]) => ({
|
|
41
|
+
kind,
|
|
42
|
+
warnings: ws,
|
|
43
|
+
}));
|
|
44
|
+
return {
|
|
45
|
+
member,
|
|
46
|
+
groups,
|
|
47
|
+
totalWarnings: memberWarnings.length,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return { members: results, noAiMembers: false };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* warning が特定のメンバー ID に関係するか判定する。
|
|
54
|
+
*
|
|
55
|
+
* 帰属ルール:
|
|
56
|
+
* - `mode_violation`: message に memberId が含まれる (board.ts が `AI (${created_by})` と書く)
|
|
57
|
+
* - `unregistered_member`: message に `"${memberId}"` が含まれる (board.ts が `created_by "${id}"` / `assignee "${id}"` と書く)
|
|
58
|
+
* - `invalid_task` / `duplicate_id`: タスク起票者を message から特定できないが、メンバーが 1 人なら
|
|
59
|
+
* そのメンバーに帰属させる。複数の AI メンバーがいる場合は全員に共通の問題として扱う (= 全員に紐付ける)
|
|
60
|
+
* - `invalid_config`: config 全体の問題なので全 AI メンバーに共通 (= 全員に紐付ける)
|
|
61
|
+
*/
|
|
62
|
+
function isWarningForMember(warning, memberId) {
|
|
63
|
+
switch (warning.kind) {
|
|
64
|
+
case 'mode_violation':
|
|
65
|
+
// board.ts: `AI (${frontmatter.created_by}) が ...`
|
|
66
|
+
return warning.message.includes(`(${memberId})`);
|
|
67
|
+
case 'unregistered_member':
|
|
68
|
+
// board.ts: `created_by "${id}" は config.members に未登録` / `assignee "${id}" ...`
|
|
69
|
+
return warning.message.includes(`"${memberId}"`);
|
|
70
|
+
case 'invalid_task':
|
|
71
|
+
case 'duplicate_id':
|
|
72
|
+
case 'invalid_config':
|
|
73
|
+
// task/config 全体の問題。AI メンバー全員に共通の診断事項として表示する
|
|
74
|
+
return true;
|
|
75
|
+
default:
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/shared/schema.js
CHANGED
|
@@ -15,6 +15,11 @@ export const UI_LANGS = ['en', 'ja'];
|
|
|
15
15
|
export const DEFAULT_PORT = 5151;
|
|
16
16
|
/** overview モードの既定ポート (波 VII)。serve の 5151 と衝突しない値・1 箇所集約 */
|
|
17
17
|
export const DEFAULT_OVERVIEW_PORT = 5152;
|
|
18
|
+
/**
|
|
19
|
+
* serve のポート自動フォールバック試行回数。起点 (--port 明示時を除く) が使用中なら
|
|
20
|
+
* 起点〜起点+この数-1 まで順に空きを探す。複数プロジェクトの同時 serve 用 (例: 5151〜5170)。
|
|
21
|
+
*/
|
|
22
|
+
export const PORT_FALLBACK_ATTEMPTS = 20;
|
|
18
23
|
// 日付は文字列のまま扱い、Date 化しない (js-yaml JSON_SCHEMA エンジンと対になる規則)。
|
|
19
24
|
// zod の datetime({ offset: true }) を使い、月 13 / 時 25 のような実在しない日時を弾く
|
|
20
25
|
// (自前正規表現は桁数だけ見て実在性を見ず、経過時間表示が NaN になりうる — PM Round 1 Minor #8)。
|
|
@@ -145,6 +150,9 @@ export const configSchema = z.object({
|
|
|
145
150
|
views: z.array(z.object({ id: z.string().min(1), hidden: z.boolean().optional() })).default([]),
|
|
146
151
|
// 関連資料ディレクトリ (波 VIII)。プロジェクトルートからの相対パス。既定は空 = 機能オフ (UI 非表示)
|
|
147
152
|
docsDirs: z.array(z.string().min(1)).default([]),
|
|
153
|
+
// 資料の書込先ディレクトリ (資料エディタ MVP)。読取 (docsDirs) と分離し、既定は空 = 書込オフ。
|
|
154
|
+
// 書込 API (POST/PUT/DELETE /api/docs) はこの配下のみ許可する (root 配下相対)。
|
|
155
|
+
docsWriteDirs: z.array(z.string().min(1)).default([]),
|
|
148
156
|
// ごみ箱 (.kanban/.trash) の同 id あたり退避世代の保持件数。任意・後方互換 (未設定は DEFAULT_TRASH_KEEP)。
|
|
149
157
|
// 破壊的書込の直前に旧版を .trash へコピー退避し、この件数を超えた古い世代を刈り取る (誤上書きの安全網)
|
|
150
158
|
trashKeep: z.number().int().positive().optional(),
|
|
@@ -275,6 +283,9 @@ export const newTaskInputSchema = z
|
|
|
275
283
|
labels: z.array(z.string().min(1)).optional(),
|
|
276
284
|
// 本文 (波 VI: テンプレートからのプリフィル用)。省略時はサーバーが既定本文テンプレートを使う
|
|
277
285
|
body: z.string().optional(),
|
|
286
|
+
// 波 VIII: 関連資料 (プロジェクトルートからの相対パス配列)。任意・後方互換。
|
|
287
|
+
// 範囲 (docsDirs / docsWriteDirs 配下か) はサーバー側 assertRelatedDocsAllowed が検証する
|
|
288
|
+
related_docs: z.array(z.string().min(1)).optional(),
|
|
278
289
|
})
|
|
279
290
|
.strict();
|
|
280
291
|
/**
|