aizuchi 0.4.0 → 0.7.2

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.
@@ -3,14 +3,45 @@
3
3
  */
4
4
  // 作業ログ見出し: 日本語テンプレ (`## 作業ログ`) と英語 (`## Work log`) の両方を許容する
5
5
  const WORK_LOG_HEADING_RE = /^#{1,6}\s*(?:作業ログ|work log)\s*$/i;
6
- // 承認痕跡: approve API が作業ログに残す `[approve]` タグ行 (F-3-4 誤検知防止 R6-1)
7
- const APPROVE_TRACE_RE = /^\s*-\s*\S.*\[approve\]/m;
6
+ // 任意の Markdown 見出し行 (作業ログセクションの終端検出に使う)
7
+ const ANY_HEADING_RE = /^#{1,6}\s/;
8
+ // 承認痕跡: approve API (app.ts) が作業ログに残す固定文言のみにマッチする (I1: `## やりとり` の
9
+ // 自由記述コメントや CLI `aizuchi log` の任意メッセージによる誤検知バイパスを防ぐため、行頭
10
+ // `- <timestamp>` 直後のアクター角括弧位置が `[approve]` で、続くメッセージが承認 API の固定文言
11
+ // `承認: proposed → todo` (app.ts:549) と完全一致する場合のみマッチする)
12
+ const APPROVE_TRACE_RE = /^-\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\[approve\]\s+承認: proposed → todo\s*$/m;
8
13
  /**
9
- * 作業ログに承認痕跡 (`[approve]` を含むログ行) があるかを判定する。
14
+ * body から `## 作業ログ` セクション (見出し直後 〜 次の見出し行 or EOF) だけを切り出す。
15
+ * 見出しが無ければ空文字列を返す (承認痕跡は正規の approve API 書込みでのみ作業ログ内に残るため、
16
+ * セクション自体が無いタスクに承認痕跡はあり得ない = 空扱いで安全側に倒す)。
17
+ */
18
+ function workLogSection(body) {
19
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
20
+ const lines = body.split(eol);
21
+ let start = -1;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ if (WORK_LOG_HEADING_RE.test(lines[i].replace(/\r$/, '').trim())) {
24
+ start = i + 1;
25
+ break;
26
+ }
27
+ }
28
+ if (start === -1)
29
+ return '';
30
+ const out = [];
31
+ for (let i = start; i < lines.length; i++) {
32
+ if (ANY_HEADING_RE.test(lines[i]))
33
+ break;
34
+ out.push(lines[i]);
35
+ }
36
+ return out.join('\n');
37
+ }
38
+ /**
39
+ * 作業ログセクションに承認痕跡 (approve API 固定文言のログ行) があるかを判定する。
10
40
  * proposed→approve した正規タスクと、AI が proposed を経ず直接 todo 起票した違反を区別するために使う。
41
+ * I1: 検索対象を `## 作業ログ` セクション内に限定する (`## やりとり` コメント経由のバイパスを防ぐ)。
11
42
  */
12
43
  export function hasApprovalTrace(body) {
13
- return APPROVE_TRACE_RE.test(body);
44
+ return APPROVE_TRACE_RE.test(workLogSection(body));
14
45
  }
15
46
  /**
16
47
  * frontmatter ブロック (先頭 `---` 〜 2 つ目の `---` の行) を header として保持したまま、
@@ -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
+ }
@@ -1,14 +1,14 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import path from 'node:path';
3
2
  import { findMemberViolations, moveInputSchema, newTaskInputSchema, NEW_TASK_BODY, summarizeZodError, taskFrontmatterSchema, taskIdSchema, } from '../../shared/schema.js';
4
3
  import { loadConfig, loadTaskView } from '../board.js';
5
4
  import { allocateNextId, createSerialQueue } from './id.js';
6
- import { FileExistsError, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
5
+ import { FileExistsError, PathValidationError, readKanbanFile, resolveKanbanReal, safeWriteFile } from './fs-safe.js';
7
6
  import { applyPinpointPatch, verifyPinpointResult } from './pinpoint.js';
8
7
  import { appendCommentLine, appendWorkLogLine, replaceBody, reviseWorkLogText } from './proposal.js';
8
+ import { assertRelatedDocsAllowed } from './related-docs-validate.js';
9
9
  import { parseTaskFile, serializeTaskFile, TaskFileParseError } from './task-file.js';
10
10
  import { nowIso, workLogTimestamp } from './time.js';
11
- import { DEFAULT_TRASH_KEEP, snapshotToTrash } from './trash.js';
11
+ import { DEFAULT_TRASH_KEEP, snapshotBestEffort } from './trash.js';
12
12
  /**
13
13
  * タスク操作のコアロジック (AI 用 CLI F-6-3 が使う)。
14
14
  *
@@ -60,13 +60,16 @@ export class TaskOps {
60
60
  * 破壊的書込 (move / revise) の直前に現内容を `.kanban/.trash/<id>.<epoch_ms>.md` へコピー退避する
61
61
  * 安全網 (ごみ箱)。HTTP ルート (app.ts) の snapshot ヘルパーと同型・同 trashKeep。
62
62
  * ベストエフォート (退避失敗は本処理を止めない)。退避は元を消さない「コピー」。
63
+ *
64
+ * M9 (LEARN#8b): 実際の退避握り潰しは snapshotBestEffort (trash.ts) に集約。resolveKanbanReal は
65
+ * ここでも throw しうるため (呼び出し元で既に成功済みだが再解決する) 従来どおり try/catch を維持する。
63
66
  */
64
67
  async snapshot(id, currentContent) {
65
68
  try {
66
69
  const kanbanReal = await resolveKanbanReal(this.projectRoot);
67
70
  const { config } = await loadConfig(this.projectRoot);
68
71
  const keep = config.trashKeep ?? DEFAULT_TRASH_KEEP;
69
- await snapshotToTrash(id, currentContent, this.kanbanDir, kanbanReal, keep);
72
+ await snapshotBestEffort(id, currentContent, this.kanbanDir, kanbanReal, keep);
70
73
  }
71
74
  catch {
72
75
  // 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
@@ -87,6 +90,17 @@ export class TaskOps {
87
90
  if (violations.length > 0) {
88
91
  throw new TaskOpError('unregistered_member', violations.join(' / '));
89
92
  }
93
+ // 波 VIII: related_docs は docsDirs / docsWriteDirs 配下の妥当な .md 相対パスのみ受け付ける
94
+ // (I3: app.ts の POST /api/tasks と同型の検証。HTTP 経路と非対称な許可リスト検証欠落を解消する)
95
+ if (parsed.data.related_docs !== undefined && parsed.data.related_docs.length > 0) {
96
+ try {
97
+ assertRelatedDocsAllowed(this.projectRoot, config, parsed.data.related_docs);
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof PathValidationError ? error.message : 'related_docs が不正です';
101
+ throw new TaskOpError('invalid_input', message);
102
+ }
103
+ }
90
104
  return this.enqueue(async () => {
91
105
  const MAX_RETRY = 5;
92
106
  for (let attempt = 0;; attempt++) {
@@ -100,6 +114,7 @@ export class TaskOps {
100
114
  created_by: parsed.data.created_by,
101
115
  priority: parsed.data.priority ?? 'medium',
102
116
  labels: parsed.data.labels ?? [],
117
+ related_docs: parsed.data.related_docs,
103
118
  blocked_type: null,
104
119
  blocked_reason: null,
105
120
  created: now,
@@ -138,7 +153,10 @@ export class TaskOps {
138
153
  return this.enqueue(async () => {
139
154
  let raw;
140
155
  try {
141
- raw = await fs.readFile(filePath, 'utf8');
156
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
157
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
158
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
159
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
142
160
  }
143
161
  catch {
144
162
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -204,7 +222,10 @@ export class TaskOps {
204
222
  return this.enqueue(async () => {
205
223
  let raw;
206
224
  try {
207
- raw = await fs.readFile(filePath, 'utf8');
225
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
226
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
227
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
228
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
208
229
  }
209
230
  catch {
210
231
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -273,7 +294,10 @@ export class TaskOps {
273
294
  return this.enqueue(async () => {
274
295
  let raw;
275
296
  try {
276
- raw = await fs.readFile(filePath, 'utf8');
297
+ // R17-D: .kanban の realpath 信頼境界を経由して読む (symlink / 中間 dir symlink は
298
+ // PathValidationError → 404 相当の not_found に倒す。C1: 生 fs.readFile は symlink を辿り
299
+ // 外部ファイル内容を .trash へ実体コピーしてリークするため使わない)
300
+ raw = await readKanbanFile(filePath, await resolveKanbanReal(this.projectRoot));
277
301
  }
278
302
  catch {
279
303
  throw new TaskOpError('not_found', `タスクが見つからない: ${id}`);
@@ -100,6 +100,27 @@ export async function snapshotToTrash(id, currentContent, kanbanDir, kanbanReal,
100
100
  await pruneTrash(id, kanbanDir, kanbanReal, trashKeep);
101
101
  return written;
102
102
  }
103
+ /**
104
+ * 破壊的書込 (move/revise/append/overwrite 等) の直前に呼ぶ、ベストエフォートな退避ラッパー
105
+ * (LEARN#8b N=3・app.ts の snapshot クロージャ / TaskOps.snapshot / docs.ts writeDoc overwrite 分岐の
106
+ * 3 箇所で重複していたロジックを集約。過去に片側ハードコード drift が実際に起きた実績あり)。
107
+ *
108
+ * `snapshotToTrash` を呼び、失敗 (containment 違反・.trash 書込不能等) は try/catch で握り潰して
109
+ * 呼び出し側の本処理 (破壊的書込) を止めない。**kanbanReal / trashKeep は呼び出し側で解決済みの値を渡すこと**
110
+ * (このヘルパー自体は config 読込や realpath 解決の失敗を吸収しない。呼び出し側がそれらを行うタイミング・
111
+ * try/catch 範囲を変えないことで既存 3 箇所の挙動を一切変えずに委譲できる)。
112
+ *
113
+ * fail-closed な削除経路 (DELETE /api/tasks・docs.ts の deleteDoc) はこのヘルパーの対象外
114
+ * (退避失敗を呼び出し側へ伝搬させて処理を中止する必要があるため、直接 `snapshotToTrash` を呼ぶ)。
115
+ */
116
+ export async function snapshotBestEffort(id, content, kanbanDir, kanbanReal, trashKeep = DEFAULT_TRASH_KEEP) {
117
+ try {
118
+ await snapshotToTrash(id, content, kanbanDir, kanbanReal, trashKeep);
119
+ }
120
+ catch {
121
+ // 退避はベストエフォート安全網。失敗しても本処理 (破壊的書込) は止めない
122
+ }
123
+ }
103
124
  /**
104
125
  * 同 id の退避世代が trashKeep を超えていれば古い順 (epoch 昇順) に削除する。
105
126
  * trashKeep 以下なら何もしない。削除失敗 (残骸) はスキップして続行する (ベストエフォート)。
@@ -0,0 +1,39 @@
1
+ /**
2
+ * AI 稼働ヒートマップ (F-8) の派生ロジック。
3
+ * 全タスクの作業ログ行の時刻を「曜日 × 時間帯」で集計する純関数。
4
+ * read-only・新データ源・新依存・新 API はゼロ。server/web の双方が型を共有する。
5
+ *
6
+ * 作業ログ行の parse は shared/digest.ts の parseWorkLogLines を流用する (再実装しない)。
7
+ * タイムゾーン近似: 作業ログ時刻は offset 無し (ローカル解釈)。stats.ts R10-4 の近似を継承する。
8
+ * member 別の絞りは不要 (全体集計でよい)。
9
+ */
10
+ import { parseWorkLogLines } from './digest.js';
11
+ /**
12
+ * 全タスクの作業ログ行の時刻を「曜日 × 時間帯」で集計する純関数。
13
+ * - 空のグリッド (7×24 の全ゼロ) を初期値にし、各ログ行の `YYYY-MM-DD HH:mm` を
14
+ * Date.parse でパースして曜日 (getDay) と時間 (getHours) を取得する。
15
+ * - parse 不能な行は静かにスキップ (誤計上を避ける)。
16
+ * - タイムゾーン近似: 作業ログは offset 無し (= ローカル / Date 組込の解釈)。
17
+ * R10-4 の既知近似を継承しコメントで明示する (JST 運用なら実害小)。
18
+ */
19
+ export function deriveActivityHeatmap(tasks) {
20
+ // 7 行 (dow) × 24 列 (hour) の全ゼログリッド
21
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
22
+ let total = 0;
23
+ for (const task of tasks) {
24
+ for (const { date, time } of parseWorkLogLines(task.body)) {
25
+ // タイムゾーン近似 (R10-4 継承): `YYYY-MM-DDThh:mm` は offset 無しのためローカル解釈される。
26
+ // 非 JST ブラウザでは実際の入力時刻とズレる可能性があるが、JST 運用なら実害小。
27
+ const ms = Date.parse(`${date}T${time}`);
28
+ if (Number.isNaN(ms))
29
+ continue;
30
+ const d = new Date(ms);
31
+ const dow = d.getDay(); // 0=日曜 … 6=土曜
32
+ const hour = d.getHours(); // 0 … 23
33
+ grid[dow][hour]++;
34
+ total++;
35
+ }
36
+ }
37
+ const maxCount = grid.reduce((m, row) => Math.max(m, ...row), 0);
38
+ return { grid, maxCount, total };
39
+ }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { setFrontmatterField } from './task-setters.js';
1
2
  /** 指定資料パスを related_docs に含むタスクを ID 昇順で返す (完全一致・正規化は呼び出し側責務) */
2
3
  export function tasksForDoc(tasks, docPath) {
3
4
  return tasks
@@ -5,15 +6,9 @@ export function tasksForDoc(tasks, docPath) {
5
6
  .map(({ id, title, status }) => ({ id, title, status }))
6
7
  .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
7
8
  }
8
- // frontmatter 境界: 先頭の `---` 行 (行末空白を許容し parseTaskFile の判定と揃える、R13-1)
9
- const FM_OPEN = /^---[ \t]*\r?\n/;
10
- // 閉じ `---` 行 (行末空白許容)
11
- const FM_CLOSE = /^---[ \t]*$/;
12
- // BOM は literal 禁止 (LEARN#13: invisible unicode は \uXXXX escape で書く)
13
- const BOM = '\uFEFF';
14
9
  /**
15
10
  * raw (frontmatter + 本文) の related_docs 行を docs で差し替えた raw を返す。
16
- * - 先頭 BOM は剥がして処理し、戻り値で復元する (parseTaskFile / applyPinpointPatch と同じ規約、R13-1)
11
+ * - 先頭 BOM は剥がして処理し、戻り値で復元する (R13-1)
17
12
  * - docs が空: related_docs 行 (flow 形式 1 行 or block 形式の key 行 + 続く `- ` 行) を除去
18
13
  * - docs が非空: JSON 文字列化した flow 形式 1 行 (`related_docs: ["a", "b"]`) に置換。
19
14
  * 既存行が無ければ `created:` 行の直前 (無ければ閉じ `---` の直前) に挿入する。
@@ -22,56 +17,7 @@ const BOM = '\uFEFF';
22
17
  * frontmatter が見つからない raw はそのまま返す (呼び出し側は「変化なし」を検出してエラー表示する)。
23
18
  */
24
19
  export function setRelatedDocsInRaw(raw, docs) {
25
- // BOM を剥がして処理し、最後に復元する (R13-1: BOM 付きタスクで silent no-op にしない)
26
- const hasBom = raw.startsWith(BOM);
27
- const body = hasBom ? raw.slice(1) : raw;
28
- const result = setRelatedDocsInner(body, docs);
29
- return hasBom ? BOM + result : result;
30
- }
31
- function setRelatedDocsInner(raw, docs) {
32
- if (!FM_OPEN.test(raw))
33
- return raw;
34
- const lines = raw.split('\n');
35
- // 閉じ `---` を探す (行 0 は開き `---`)
36
- let closeIdx = -1;
37
- for (let i = 1; i < lines.length; i++) {
38
- if (FM_CLOSE.test(lines[i].replace(/\r$/, ''))) {
39
- closeIdx = i;
40
- break;
41
- }
42
- }
43
- if (closeIdx === -1)
44
- return raw;
45
- // 既存 related_docs 行 (flow 1 行 or block 形式) を frontmatter 内から除去
46
- const kept = [];
47
- let i = 1;
48
- while (i < closeIdx) {
49
- const line = lines[i].replace(/\r$/, '');
50
- if (/^related_docs\s*:/.test(line)) {
51
- i++;
52
- // block 形式: 続く `- ` 行も取り除く。ゼロインデント (PyYAML 既定・valid YAML) も対象 (R13-4)。
53
- // 次のトップレベル key 行は `- ` で始まらないため、この条件で安全に打ち切れる
54
- while (i < closeIdx && /^\s*-\s/.test(lines[i].replace(/\r$/, '')))
55
- i++;
56
- continue;
57
- }
58
- kept.push(lines[i]);
59
- i++;
60
- }
61
- // 挿入する新しい行 (docs 非空のときのみ)。JSON.stringify は YAML double-quoted として valid
62
- const newLine = docs.length > 0 ? `related_docs: [${docs.map((d) => JSON.stringify(d)).join(', ')}]` : null;
63
- if (newLine !== null) {
64
- const createdIdx = kept.findIndex((l) => /^created\s*:/.test(l.replace(/\r$/, '')));
65
- if (createdIdx !== -1) {
66
- kept.splice(createdIdx, 0, newLine);
67
- }
68
- else {
69
- kept.push(newLine);
70
- }
71
- }
72
- const head = [lines[0], ...kept].join('\n');
73
- const tail = lines.slice(closeIdx).join('\n');
74
- // 改行コードは元 raw のものが保たれる (split('\n') で \r が各行末に残るため CRLF はそのまま。
75
- // 新規挿入行のみ LF になるが、混在しても YAML としては valid)
76
- return `${head}\n${tail}`;
20
+ // JSON.stringify flow 形式: YAML double-quoted として常に valid (特殊文字を含むパスも安全)
21
+ const serialized = docs.length > 0 ? `[${docs.map((d) => JSON.stringify(d)).join(', ')}]` : null;
22
+ return setFrontmatterField(raw, 'related_docs', serialized);
77
23
  }
@@ -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)。
@@ -28,8 +33,9 @@ export const taskIdSchema = z
28
33
  // 1 行テキスト (title 等。改行を含むと行ベースの frontmatter 処理が壊れる)
29
34
  const singleLineString = z.string().min(1).regex(/^[^\r\n]+$/, '1 行で書く (改行不可)');
30
35
  export const memberSchema = z.object({
31
- // スラッグ (タスクファイルの created_by / assignee に書かれる安定 ID)
32
- id: z.string().min(1),
36
+ // スラッグ (タスクファイルの created_by / assignee に書かれる安定 ID)
37
+ // M3: `]` と改行を禁止する (parseWorkLogLines の `\[([^\]]+)\]` が member/message を誤分割しないため)
38
+ id: z.string().min(1).regex(/^[^\]\r\n]+$/, 'member id に ] や改行は使えません'),
33
39
  // 表示名 (任意)。未指定は id を表示 (columns label と同じパターン)。表示名の変更は id に影響しない
34
40
  label: z.string().min(1).optional(),
35
41
  type: z.enum(MEMBER_TYPES),
@@ -145,6 +151,9 @@ export const configSchema = z.object({
145
151
  views: z.array(z.object({ id: z.string().min(1), hidden: z.boolean().optional() })).default([]),
146
152
  // 関連資料ディレクトリ (波 VIII)。プロジェクトルートからの相対パス。既定は空 = 機能オフ (UI 非表示)
147
153
  docsDirs: z.array(z.string().min(1)).default([]),
154
+ // 資料の書込先ディレクトリ (資料エディタ MVP)。読取 (docsDirs) と分離し、既定は空 = 書込オフ。
155
+ // 書込 API (POST/PUT/DELETE /api/docs) はこの配下のみ許可する (root 配下相対)。
156
+ docsWriteDirs: z.array(z.string().min(1)).default([]),
148
157
  // ごみ箱 (.kanban/.trash) の同 id あたり退避世代の保持件数。任意・後方互換 (未設定は DEFAULT_TRASH_KEEP)。
149
158
  // 破壊的書込の直前に旧版を .trash へコピー退避し、この件数を超えた古い世代を刈り取る (誤上書きの安全網)
150
159
  trashKeep: z.number().int().positive().optional(),
@@ -201,7 +210,7 @@ export const taskFrontmatterSchema = z
201
210
  message: 'pending のタスクは blocked_type (decision / external / dependency) が必須',
202
211
  });
203
212
  }
204
- if (task.blocked_reason === null) {
213
+ if (task.blocked_reason === null || task.blocked_reason.trim().length === 0) {
205
214
  ctx.addIssue({
206
215
  code: z.ZodIssueCode.custom,
207
216
  path: ['blocked_reason'],
@@ -275,6 +284,9 @@ export const newTaskInputSchema = z
275
284
  labels: z.array(z.string().min(1)).optional(),
276
285
  // 本文 (波 VI: テンプレートからのプリフィル用)。省略時はサーバーが既定本文テンプレートを使う
277
286
  body: z.string().optional(),
287
+ // 波 VIII: 関連資料 (プロジェクトルートからの相対パス配列)。任意・後方互換。
288
+ // 範囲 (docsDirs / docsWriteDirs 配下か) はサーバー側 assertRelatedDocsAllowed が検証する
289
+ related_docs: z.array(z.string().min(1)).optional(),
278
290
  })
279
291
  .strict();
280
292
  /**
@@ -290,9 +302,19 @@ export const moveInputSchema = z
290
302
  })
291
303
  .strict()
292
304
  .superRefine((input, ctx) => {
293
- // pending 行きへの blocked_* 同梱はクライアントのバグの兆候なので黙殺せず 400 にする
294
- if (input.status === 'pending')
305
+ if (input.status === 'pending') {
306
+ // M1: 空白のみの blocked_reason は無効 (taskFrontmatterSchema pending 必須チェックと同じ基準。
307
+ // ここで弾くのは入力検証段階のフェイルファストのためで、自動 trim 保存はしない)
308
+ if (input.blocked_reason != null && input.blocked_reason.trim().length === 0) {
309
+ ctx.addIssue({
310
+ code: z.ZodIssueCode.custom,
311
+ path: ['blocked_reason'],
312
+ message: 'blocked_reason は空白のみでは指定できません',
313
+ });
314
+ }
295
315
  return;
316
+ }
317
+ // 非 pending 行きへの blocked_* 同梱はクライアントのバグの兆候なので黙殺せず 400 にする
296
318
  if (input.blocked_type != null) {
297
319
  ctx.addIssue({
298
320
  code: z.ZodIssueCode.custom,
@@ -154,7 +154,10 @@ function isCompletionRecord(message) {
154
154
  }
155
155
  /**
156
156
  * done タスクの完了時刻を決める (epoch ms)。
157
- * 優先順位: (1) 完了記録 (isCompletionRecord) を満たす作業ログ最終行の日時 無ければ (2) updated。
157
+ * 優先順位: (1) 完了記録 (isCompletionRecord) を満たす作業ログ行のうち「時刻が最大」のもの
158
+ * 無ければ (2) updated。出現位置の末尾ではなく時刻最大で選ぶ (lastActivity と同方針、I5: 手編集で
159
+ * 過去日付の完了記録を後から追記するバックフィルがあると、末尾一致では新しい完了記録より古い
160
+ * バックフィル行を誤って採用しうるため)。
158
161
  * 作業ログは `YYYY-MM-DD HH:mm` (タイムゾーン無し) なので、updated のタイムゾーンに合わせず
159
162
  * ローカル解釈の差を避けるため UTC ではなく Date.parse に委ねる (日別ビンは日付文字列で行う)。
160
163
  * R10-4 既知近似: 作業ログ時刻はローカル解釈・created/updated はオフセット付き絶対時刻のため、
@@ -163,13 +166,18 @@ function isCompletionRecord(message) {
163
166
  function completionMs(task) {
164
167
  // `## やりとり` のコメント行に完了キーワードがあっても拾わないよう、作業ログセクションに限定する (R-review #3)。
165
168
  const logs = parseWorkLogLines(workLogBody(task.body));
166
- for (let i = logs.length - 1; i >= 0; i--) {
167
- if (isCompletionRecord(logs[i].message)) {
168
- const ms = Date.parse(`${logs[i].date}T${logs[i].time}`);
169
- if (!Number.isNaN(ms))
170
- return ms;
171
- }
169
+ let best = null;
170
+ for (const log of logs) {
171
+ if (!isCompletionRecord(log.message))
172
+ continue;
173
+ const ms = Date.parse(`${log.date}T${log.time}`);
174
+ if (Number.isNaN(ms))
175
+ continue;
176
+ if (best === null || ms > best)
177
+ best = ms;
172
178
  }
179
+ if (best !== null)
180
+ return best;
173
181
  return parseIsoMs(task.updated);
174
182
  }
175
183
  /** 完了時刻 ms から日別ビンのキー (`YYYY-MM-DD`) を作る (ローカル日付) */
@@ -249,7 +257,8 @@ export function deriveStats(tasks, now, options = {}) {
249
257
  return { status, count: group.length, avgDays: null, maxDays: null };
250
258
  }
251
259
  const avgMs = dwells.reduce((s, v) => s + v, 0) / dwells.length;
252
- return { status, count: group.length, avgDays: toDays(avgMs), maxDays: toDays(Math.max(...dwells)) };
260
+ const maxMs = dwells.reduce((m, v) => (v > m ? v : m), dwells[0]);
261
+ return { status, count: group.length, avgDays: toDays(avgMs), maxDays: toDays(maxMs) };
253
262
  });
254
263
  // 4. member 別完了数 (件数降順 → member 名昇順、未割当は最後)
255
264
  const completionsByMember = [...byMember.entries()]